diff --git a/internal/api/js/chrome/browser.go b/internal/api/js/chrome/browser.go index 7654fd5..79070f1 100644 --- a/internal/api/js/chrome/browser.go +++ b/internal/api/js/chrome/browser.go @@ -19,6 +19,7 @@ import ( var ( browserInstance *Browser browserInstanceMu = &sync.Mutex{} + origins *Origins ) // GlobalBrowser returns the browser singleton, making it if needed. @@ -28,6 +29,7 @@ func GlobalBrowser() (*Browser, error) { if browserInstance == nil { var err error browserInstance, err = NewBrowser() + origins = NewOrigins() return browserInstance, err } return browserInstance, nil @@ -106,3 +108,29 @@ func (b *Browser) NewTab(baseJSURL string, onConsoleLog func(s string)) (*Tab, e cancel: closeTab, }, nil } + +// For clients which want persistent storage, we need to ensure when the browser +// starts up a 2nd+ time we serve the same URL so the browser uses the same origin +type Origins struct { + clientToBaseURL map[string]string + mu *sync.RWMutex +} + +func NewOrigins() *Origins { + return &Origins{ + clientToBaseURL: make(map[string]string), + mu: &sync.RWMutex{}, + } +} + +func (o *Origins) StoreBaseURL(userID, deviceID, baseURL string) { + o.mu.Lock() + defer o.mu.Unlock() + o.clientToBaseURL[userID+deviceID] = baseURL +} + +func (o *Origins) GetBaseURL(userID, deviceID string) (baseURL string) { + o.mu.RLock() + defer o.mu.RUnlock() + return o.clientToBaseURL[userID+deviceID] +} diff --git a/internal/api/js/chrome/chrome.go b/internal/api/js/chrome/chrome.go index 398af64..3c51cf6 100644 --- a/internal/api/js/chrome/chrome.go +++ b/internal/api/js/chrome/chrome.go @@ -52,7 +52,8 @@ func MustRunAsyncFn[T any](t ct.TestLike, ctx context.Context, js string) *T { return result } -func RunHeadless(onConsoleLog func(s string), listenPort int) (*Tab, error) { +// Run a headless JS SDK instance for the given user/device ID. +func RunHeadless(userID, deviceID string, onConsoleLog func(s string)) (*Tab, error) { // make a Chrome browser browser, err := GlobalBrowser() if err != nil { @@ -60,9 +61,12 @@ func RunHeadless(onConsoleLog func(s string), listenPort int) (*Tab, error) { } // Host the JS SDK - baseJSURL, closeSDKInstance, err := NewJSSDKWebsite(JSSDKInstanceOpts{ - Port: listenPort, - }) + baseURL := origins.GetBaseURL(userID, deviceID) + opts, err := NewJSSDKInstanceOptsFromURL(baseURL, userID, deviceID) + if err != nil { + return nil, fmt.Errorf("NewJSSDKInstanceOptsFromURL: %v", err) + } + baseJSURL, closeSDKInstance, err := NewJSSDKWebsite(opts) if err != nil { return nil, fmt.Errorf("failed to create new js sdk instance: %s", err) } @@ -73,6 +77,8 @@ func RunHeadless(onConsoleLog func(s string), listenPort int) (*Tab, error) { closeSDKInstance() return nil, fmt.Errorf("failed to create new tab: %s", err) } + // we will have a random high numbered port now, so remember it. + origins.StoreBaseURL(userID, deviceID, baseJSURL) // when we close the tab, close the hosted files too tab.SetCloseServer(closeSDKInstance) diff --git a/internal/api/js/chrome/tab.go b/internal/api/js/chrome/tab.go index 1eb5b89..d3e38a0 100644 --- a/internal/api/js/chrome/tab.go +++ b/internal/api/js/chrome/tab.go @@ -7,22 +7,60 @@ import ( "io/fs" "net" "net/http" + "net/url" + "regexp" + "strconv" + "strings" "sync" ) //go:embed dist var jsSDKDistDirectory embed.FS +var nonAlphanumericRegex = regexp.MustCompile(`[^a-zA-Z0-9]+`) + type JSSDKInstanceOpts struct { - // The specific port this instance should be hosted on. + // Required. The prefix to use when constructing base URLs. + // This is used to namespace storage between tests by prefixing the URL e.g + // 'foo.localhost:12345' vs 'bar.localhost:12345'. We cannot simply use the + // port number alone because it is randomly allocated, which means the port + // number can be reused. If this happens, the 2nd+ test with the same port + // number will fail with: + // 'Error: the account in the store doesn't match the account in the constructor' + // By prefixing, we ensure we are treated as a different origin whilst keeping + // routing the same. + HostPrefix string + // Optional. The specific port this instance should be hosted on. + // If 0, uses a random high numbered port. // This is crucial for persistent storage which relies on a stable port number // across restarts. Port int } +// NewJSSDKInstanceOptsFromURL returns SDK options based on a pre-existing base URL. If the +// base URL doesn't exist yet, create the information from the provided user/device ID. +func NewJSSDKInstanceOptsFromURL(baseURL, userID, deviceID string) (*JSSDKInstanceOpts, error) { + if baseURL == "" { + return &JSSDKInstanceOpts{ + HostPrefix: nonAlphanumericRegex.ReplaceAllString(userID+deviceID, ""), + }, nil + } + u, _ := url.Parse(baseURL) + portStr := u.Port() + port, err := strconv.Atoi(portStr) + if portStr == "" || err != nil { + return nil, fmt.Errorf("failed to extract port from base url %s", baseURL) + } + + return &JSSDKInstanceOpts{ + HostPrefix: strings.Split(u.Hostname(), ".")[0], + Port: port, + }, nil +} + // NewJSSDKWebsite hosts the JS SDK HTML/JS on a random high-numbered port // and runs a Go web server to serve those files. -func NewJSSDKWebsite(opts JSSDKInstanceOpts) (baseURL string, close func(), err error) { +func NewJSSDKWebsite(opts *JSSDKInstanceOpts) (baseURL string, close func(), err error) { // strip /dist so /index.html loads correctly as does /assets/xxx.js c, err := fs.Sub(jsSDKDistDirectory, "dist") if err != nil { @@ -42,7 +80,9 @@ func NewJSSDKWebsite(opts JSSDKInstanceOpts) (baseURL string, close func(), err if err != nil { panic(err) } - baseURL = "http://" + ln.Addr().String() + baseURL = fmt.Sprintf( + "http://%s.localhost:%v", opts.HostPrefix, ln.Addr().(*net.TCPAddr).Port, + ) fmt.Println("JS SDK listening on", baseURL) wg.Done() srv.Serve(ln) diff --git a/internal/api/js/js.go b/internal/api/js/js.go index 14650a6..bc0506e 100644 --- a/internal/api/js/js.go +++ b/internal/api/js/js.go @@ -3,9 +3,7 @@ package js import ( "encoding/json" "fmt" - "net/url" "os" - "strconv" "strings" "sync" "sync/atomic" @@ -23,10 +21,6 @@ const ( indexedDBCryptoName = "complement-crypto:crypto" ) -// For clients which want persistent storage, we need to ensure when the browser -// starts up a 2nd+ time we serve the same URL so the browser uses the same origin -var userDeviceToPort = map[string]int{} - var logFile *os.File func SetupJSLogs(filename string) { @@ -69,8 +63,7 @@ func NewJSClient(t ct.TestLike, opts api.ClientCreationOpts) (api.Client, error) opts: opts, verificationChannelMu: &sync.Mutex{}, } - portKey := opts.UserID + opts.DeviceID - tab, err := chrome.RunHeadless(func(s string) { + tab, err := chrome.RunHeadless(opts.UserID, opts.DeviceID, func(s string) { writeToLog("[%s,%s] console.log %s\n", opts.UserID, opts.DeviceID, s) msg := unpackControlMessage(t, s) @@ -86,7 +79,7 @@ func NewJSClient(t ct.TestLike, opts api.ClientCreationOpts) (api.Client, error) for _, l := range listeners { l(msg) } - }, userDeviceToPort[portKey]) + }) if err != nil { return nil, fmt.Errorf("failed to RunHeadless: %s", err) } @@ -117,15 +110,6 @@ func NewJSClient(t ct.TestLike, opts api.ClientCreationOpts) (api.Client, error) await window.__store.startup(); `, indexedDBName)) store = "window.__store" - //cryptoStore = fmt.Sprintf(`new IndexedDBCryptoStore(indexedDB, "%s")`, indexedDBCryptoName) - // remember the port for same-origin to remember the store - u, _ := url.Parse(tab.BaseURL) - portStr := u.Port() - port, err := strconv.Atoi(portStr) - if portStr == "" || err != nil { - ct.Fatalf(t, "failed to extract port from base url %s", tab.BaseURL) - } - userDeviceToPort[portKey] = port t.Logf("user=%s device=%s will be served from %s due to persistent storage", opts.UserID, opts.DeviceID, tab.BaseURL) }