internal/frontend: move fetchserver and versions to their own packages

Moving fetchserver to its own package will allow us to to
move the fetch logic, which isn't used by cmd/pkgsite because it
doesn't have a postgres database, out of the frontend and pkgsite. The
tests, which depend on a postgres database are moved out too, removing
a few users of the postgres database in the tests. The versions code
is moved into its own package as well so it can be used by the
fetchserver code without depending on the frontend package.

Most of server_test.go, which was testing features that are provided
by what is now the FetchServer has been moved to the fetchserver
package. To ensure that git properly realizes it as a move, the rest
of server_test has been moved to frontend_test.

In some cases I made copies of functions (absoluteTime,
insertTestModules) instead of creating intermediate packages to
contain them.

For golang/go#61399

Change-Id: Ic94419f9d75f766e289cc3fb9a1521173cefb4d1
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/519335
Reviewed-by: Robert Findley <rfindley@google.com>
kokoro-CI: kokoro <noreply+kokoro@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Run-TryBot: Michael Matloob <matloob@golang.org>
diff --git a/cmd/frontend/main.go b/cmd/frontend/main.go
index d2a4fd1..145f493 100644
--- a/cmd/frontend/main.go
+++ b/cmd/frontend/main.go
@@ -22,6 +22,7 @@
 	"golang.org/x/pkgsite/internal/fetch"
 	"golang.org/x/pkgsite/internal/fetchdatasource"
 	"golang.org/x/pkgsite/internal/frontend"
+	"golang.org/x/pkgsite/internal/frontend/fetchserver"
 	"golang.org/x/pkgsite/internal/log"
 	"golang.org/x/pkgsite/internal/middleware"
 	"golang.org/x/pkgsite/internal/middleware/timeout"
@@ -100,7 +101,7 @@
 		// per-request connection.
 		fetchQueue, err = gcpqueue.New(ctx, cfg, queueName, *workers, expg,
 			func(ctx context.Context, modulePath, version string) (int, error) {
-				return frontend.FetchAndUpdateState(ctx, modulePath, version, proxyClient, sourceClient, db)
+				return fetchserver.FetchAndUpdateState(ctx, modulePath, version, proxyClient, sourceClient, db)
 			})
 		if err != nil {
 			log.Fatalf(ctx, "gcpqueue.New: %v", err)
@@ -115,7 +116,7 @@
 	staticSource := template.TrustedSourceFromFlag(flag.Lookup("static").Value)
 	// TODO: Can we use a separate queue for the fetchServer and for the Server?
 	// It would help differentiate ownership.
-	fetchServer := &frontend.FetchServer{
+	fetchServer := &fetchserver.FetchServer{
 		Queue:                fetchQueue,
 		TaskIDChangeInterval: config.TaskIDChangeIntervalFrontend,
 	}
@@ -154,8 +155,8 @@
 	views := append(dcensus.ServerViews,
 		postgres.SearchLatencyDistribution,
 		postgres.SearchResponseCount,
-		frontend.FetchLatencyDistribution,
-		frontend.FetchResponseCount,
+		fetchserver.FetchLatencyDistribution,
+		fetchserver.FetchResponseCount,
 		frontend.VersionTypeCount,
 		middleware.CacheResultCount,
 		middleware.CacheErrorCount,
diff --git a/internal/frontend/breadcrumb.go b/internal/frontend/breadcrumb.go
index 34c29f5..9277516 100644
--- a/internal/frontend/breadcrumb.go
+++ b/internal/frontend/breadcrumb.go
@@ -8,6 +8,7 @@
 	"path"
 
 	"golang.org/x/pkgsite/internal"
+	"golang.org/x/pkgsite/internal/frontend/versions"
 	"golang.org/x/pkgsite/internal/stdlib"
 	"golang.org/x/pkgsite/internal/version"
 )
@@ -69,7 +70,7 @@
 	for i := 1; i < len(dirs); i++ {
 		href := "/" + dirs[i]
 		if requestedVersion != version.Latest {
-			href += "@" + linkVersion(modPath, requestedVersion, requestedVersion)
+			href += "@" + versions.LinkVersion(modPath, requestedVersion, requestedVersion)
 		}
 		el := dirs[i]
 		if i != len(dirs)-1 {
diff --git a/internal/frontend/client.go b/internal/frontend/client.go
index 978c87b..36b7741 100644
--- a/internal/frontend/client.go
+++ b/internal/frontend/client.go
@@ -14,6 +14,7 @@
 
 	"golang.org/x/pkgsite/internal/auth"
 	"golang.org/x/pkgsite/internal/derrors"
+	"golang.org/x/pkgsite/internal/frontend/versions"
 )
 
 // A Client for interacting with the frontend. This is only used for tests.
@@ -40,14 +41,14 @@
 
 // GetVersions returns a VersionsDetails for the specified pkgPath.
 // This is only used for tests.
-func (c *Client) GetVersions(pkgPath string) (_ *VersionsDetails, err error) {
+func (c *Client) GetVersions(pkgPath string) (_ *versions.VersionsDetails, err error) {
 	defer derrors.Wrap(&err, "GetVersions(%q)", pkgPath)
 	u := fmt.Sprintf("%s/%s?tab=versions&content=json", c.url, pkgPath)
 	body, err := c.fetchJSONPage(u)
 	if err != nil {
 		return nil, err
 	}
-	var vd VersionsDetails
+	var vd versions.VersionsDetails
 	if err := json.Unmarshal(body, &vd); err != nil {
 		return nil, fmt.Errorf("json.Unmarshal: %v:\nDoes GO_DISCOVERY_SERVE_STATS=true on the frontend?", err)
 	}
diff --git a/internal/frontend/details.go b/internal/frontend/details.go
index 1aac1ee..7870b98 100644
--- a/internal/frontend/details.go
+++ b/internal/frontend/details.go
@@ -15,7 +15,6 @@
 	"golang.org/x/pkgsite/internal/frontend/urlinfo"
 	mstats "golang.org/x/pkgsite/internal/middleware/stats"
 
-	"github.com/google/safehtml/template"
 	"go.opencensus.io/stats"
 	"go.opencensus.io/stats/view"
 	"go.opencensus.io/tag"
@@ -65,7 +64,7 @@
 		}
 	}
 	if !urlinfo.IsSupportedVersion(urlInfo.FullPath, urlInfo.RequestedVersion) {
-		return invalidVersionError(urlInfo.FullPath, urlInfo.RequestedVersion)
+		return serrors.InvalidVersionError(urlInfo.FullPath, urlInfo.RequestedVersion)
 	}
 	if urlPath := stdlibRedirectURL(urlInfo.FullPath); urlPath != "" {
 		http.Redirect(w, r, urlPath, http.StatusMovedPermanently)
@@ -91,30 +90,6 @@
 	return "/" + urlPath2
 }
 
-func invalidVersionError(fullPath, requestedVersion string) error {
-	return &serrors.ServerError{
-		Status: http.StatusBadRequest,
-		Epage: &page.ErrorPage{
-			MessageTemplate: template.MakeTrustedTemplate(`
-					<h3 class="Error-message">{{.Version}} is not a valid semantic version.</h3>
-					<p class="Error-message">
-					  To search for packages like {{.Path}}, <a href="/search?q={{.Path}}">click here</a>.
-					</p>`),
-			MessageData: struct{ Path, Version string }{fullPath, requestedVersion},
-		},
-	}
-}
-
-func datasourceNotSupportedErr() error {
-	return &serrors.ServerError{
-		Status: http.StatusFailedDependency,
-		Epage: &page.ErrorPage{
-			MessageTemplate: template.MakeTrustedTemplate(
-				`<h3 class="Error-message">This page is not supported by this datasource.</h3>`),
-		},
-	}
-}
-
 var (
 	keyVersionType     = tag.MustNewKey("frontend.version_type")
 	versionTypeResults = stats.Int64(
diff --git a/internal/frontend/directory.go b/internal/frontend/directory.go
index b809d88..fa1fd08 100644
--- a/internal/frontend/directory.go
+++ b/internal/frontend/directory.go
@@ -10,6 +10,7 @@
 	"strings"
 
 	"golang.org/x/pkgsite/internal"
+	"golang.org/x/pkgsite/internal/frontend/versions"
 	"golang.org/x/pkgsite/internal/stdlib"
 	"golang.org/x/pkgsite/internal/version"
 )
@@ -104,7 +105,7 @@
 			continue
 		}
 		mods = append(mods, &DirectoryInfo{
-			URL:      constructUnitURL(m.ModulePath, m.ModulePath, version.Latest),
+			URL:      versions.ConstructUnitURL(m.ModulePath, m.ModulePath, version.Latest),
 			Suffix:   suffix,
 			IsModule: true,
 		})
@@ -125,8 +126,8 @@
 			continue
 		}
 		sdirs = append(sdirs, &DirectoryInfo{
-			URL: constructUnitURL(pm.Path, um.ModulePath,
-				linkVersion(um.ModulePath, requestedVersion, um.Version)),
+			URL: versions.ConstructUnitURL(pm.Path, um.ModulePath,
+				versions.LinkVersion(um.ModulePath, requestedVersion, um.Version)),
 			Suffix:   internal.Suffix(pm.Path, um.Path),
 			Synopsis: pm.Synopsis,
 		})
diff --git a/internal/frontend/404.go b/internal/frontend/fetchserver/404.go
similarity index 90%
rename from internal/frontend/404.go
rename to internal/frontend/fetchserver/404.go
index 958701a..915ec9c 100644
--- a/internal/frontend/404.go
+++ b/internal/frontend/fetchserver/404.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-package frontend
+package fetchserver
 
 import (
 	"context"
@@ -15,7 +15,6 @@
 	"strings"
 	"time"
 
-	"github.com/google/safehtml/template"
 	"github.com/google/safehtml/template/uncheckedconversions"
 	"golang.org/x/pkgsite/internal"
 	"golang.org/x/pkgsite/internal/cookie"
@@ -24,25 +23,12 @@
 	"golang.org/x/pkgsite/internal/frontend/page"
 	"golang.org/x/pkgsite/internal/frontend/serrors"
 	"golang.org/x/pkgsite/internal/frontend/urlinfo"
+	"golang.org/x/pkgsite/internal/frontend/versions"
 	"golang.org/x/pkgsite/internal/log"
 	"golang.org/x/pkgsite/internal/stdlib"
 	"golang.org/x/pkgsite/internal/version"
 )
 
-// errUnitNotFoundWithoutFetch returns a 404 with instructions to the user on
-// how to manually fetch the package. No fetch button is provided. This is used
-// for very large modules or modules that previously 500ed.
-var errUnitNotFoundWithoutFetch = &serrors.ServerError{
-	Status: http.StatusNotFound,
-	Epage: &page.ErrorPage{
-		MessageTemplate: template.MakeTrustedTemplate(`
-					    <h3 class="Error-message">{{.StatusText}}</h3>
-					    <p class="Error-message">Check that you entered the URL correctly or try fetching it following the
-                        <a href="/about#adding-a-package">instructions here</a>.</p>`),
-		MessageData: struct{ StatusText string }{http.StatusText(http.StatusNotFound)},
-	},
-}
-
 // servePathNotFoundPage serves a 404 page for the requested path, or redirects
 // the user to an appropriate location.
 func (s *FetchServer) ServePathNotFoundPage(w http.ResponseWriter, r *http.Request,
@@ -98,7 +84,7 @@
 		// We will only reach a 2xx status if we found a row in version_map
 		// matching exactly the requested path.
 		if fr.resolvedVersion != requestedVersion {
-			u := constructUnitURL(fullPath, fr.goModPath, fr.resolvedVersion)
+			u := versions.ConstructUnitURL(fullPath, fr.goModPath, fr.resolvedVersion)
 			http.Redirect(w, r, u, http.StatusFound)
 			return
 		}
@@ -115,16 +101,16 @@
 		if fr.goModPath == fullPath {
 			// The redirectPath and the fullpath are the same. Do not redirect
 			// to avoid ending up in a loop.
-			return errUnitNotFoundWithoutFetch
+			return serrors.ErrUnitNotFoundWithoutFetch
 		}
 		vm, err := db.GetVersionMap(ctx, fr.goModPath, version.Latest)
 		if (err != nil && !errors.Is(err, derrors.NotFound)) ||
 			(vm != nil && vm.Status != http.StatusOK) {
 			// We attempted to fetch the canonical module path before and were
 			// not successful. Do not redirect this request.
-			return errUnitNotFoundWithoutFetch
+			return serrors.ErrUnitNotFoundWithoutFetch
 		}
-		u := constructUnitURL(fr.goModPath, fr.goModPath, version.Latest)
+		u := versions.ConstructUnitURL(fr.goModPath, fr.goModPath, version.Latest)
 		cookie.Set(w, cookie.AlternativeModuleFlash, fullPath, u)
 		http.Redirect(w, r, u, http.StatusFound)
 		return nil
@@ -178,14 +164,14 @@
 	if m[1] != "" {
 		p = m[0] + m[1]
 	}
-	return constructUnitURL(p, p, version.Latest)
+	return versions.ConstructUnitURL(p, p, version.Latest)
 }
 
 // pathNotFoundError returns a page with an option on how to
 // add a package or module to the site.
 func pathNotFoundError(ctx context.Context, fullPath, requestedVersion string) error {
 	if !urlinfo.IsSupportedVersion(fullPath, requestedVersion) {
-		return invalidVersionError(fullPath, requestedVersion)
+		return serrors.InvalidVersionError(fullPath, requestedVersion)
 	}
 	if stdlib.Contains(fullPath) {
 		if experiment.IsActive(ctx, internal.ExperimentEnableStdFrontendFetch) {
diff --git a/internal/frontend/404_test.go b/internal/frontend/fetchserver/404_test.go
similarity index 99%
rename from internal/frontend/404_test.go
rename to internal/frontend/fetchserver/404_test.go
index 145ca2a..a087613 100644
--- a/internal/frontend/404_test.go
+++ b/internal/frontend/fetchserver/404_test.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-package frontend
+package fetchserver
 
 import (
 	"context"
diff --git a/internal/frontend/fetch.go b/internal/frontend/fetchserver/fetch.go
similarity index 99%
rename from internal/frontend/fetch.go
rename to internal/frontend/fetchserver/fetch.go
index dd725e6..038334b 100644
--- a/internal/frontend/fetch.go
+++ b/internal/frontend/fetchserver/fetch.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-package frontend
+package fetchserver
 
 import (
 	"context"
@@ -101,7 +101,7 @@
 	defer derrors.Wrap(&err, "serveFetch(%q)", r.URL.Path)
 	if _, ok := ds.(internal.PostgresDB); !ok {
 		// There's no reason for other DataSources to need this codepath.
-		return datasourceNotSupportedErr()
+		return serrors.DatasourceNotSupportedError()
 	}
 	if r.Method != http.MethodPost {
 		// If a user makes a GET request, treat this as a request for the
diff --git a/internal/frontend/fetch_test.go b/internal/frontend/fetchserver/fetch_test.go
similarity index 76%
rename from internal/frontend/fetch_test.go
rename to internal/frontend/fetchserver/fetch_test.go
index 7a90a38..a4eb381 100644
--- a/internal/frontend/fetch_test.go
+++ b/internal/frontend/fetchserver/fetch_test.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-package frontend
+package fetchserver
 
 import (
 	"context"
@@ -12,13 +12,19 @@
 	"time"
 
 	"github.com/google/go-cmp/cmp"
+	"github.com/google/safehtml/template"
 	"golang.org/x/pkgsite/internal"
 	"golang.org/x/pkgsite/internal/derrors"
+	"golang.org/x/pkgsite/internal/frontend"
 	"golang.org/x/pkgsite/internal/postgres"
 	"golang.org/x/pkgsite/internal/proxy/proxytest"
+	"golang.org/x/pkgsite/internal/queue"
+	"golang.org/x/pkgsite/internal/source"
 	"golang.org/x/pkgsite/internal/testing/sample"
 	"golang.org/x/pkgsite/internal/testing/testhelper"
 	"golang.org/x/pkgsite/internal/version"
+	"golang.org/x/pkgsite/static"
+	thirdparty "golang.org/x/pkgsite/third_party"
 )
 
 var (
@@ -39,6 +45,45 @@
 	}
 )
 
+func newTestServerWithFetch(t *testing.T, proxyModules []*proxytest.Module, cacher frontend.Cacher) (*frontend.Server, *FetchServer, http.Handler, func()) {
+	t.Helper()
+	proxyClient, teardown := proxytest.SetupTestClient(t, proxyModules)
+	sourceClient := source.NewClient(sourceTimeout)
+	ctx := context.Background()
+
+	q := queue.NewInMemory(ctx, 1, nil,
+		func(ctx context.Context, mpath, version string) (int, error) {
+			return FetchAndUpdateState(ctx, mpath, version, proxyClient, sourceClient, testDB)
+		})
+
+	f := &FetchServer{
+		Queue:                q,
+		TaskIDChangeInterval: 10 * time.Minute,
+	}
+
+	s, err := frontend.NewServer(frontend.ServerConfig{
+		FetchServer:      f,
+		DataSourceGetter: func(context.Context) internal.DataSource { return testDB },
+		Queue:            q,
+		TemplateFS:       template.TrustedFSFromEmbed(static.FS),
+		// Use the embedded FSs here to make sure they're tested.
+		// Integration tests will use the actual directories.
+		StaticFS:     static.FS,
+		ThirdPartyFS: thirdparty.FS,
+		StaticPath:   "../../static",
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	mux := http.NewServeMux()
+	s.Install(mux.Handle, cacher, nil)
+
+	return s, f, mux, func() {
+		teardown()
+		postgres.ResetTestDB(testDB, t)
+	}
+}
+
 func TestFetch(t *testing.T) {
 	for _, test := range []struct {
 		name, fullPath, version, want string
@@ -80,13 +125,13 @@
 		},
 	} {
 		t.Run(test.name, func(t *testing.T) {
-			s, f, _, teardown := newTestServerWithFetch(t, testModulesForProxy, nil)
+			_, f, _, teardown := newTestServerWithFetch(t, testModulesForProxy, nil)
 			defer teardown()
 
 			ctx, cancel := context.WithTimeout(context.Background(), testFetchTimeout)
 			defer cancel()
 
-			status, responseText := f.fetchAndPoll(ctx, s.getDataSource(ctx), testModulePath, test.fullPath, test.version)
+			status, responseText := f.fetchAndPoll(ctx, testDB, testModulePath, test.fullPath, test.version)
 			if status != http.StatusOK {
 				t.Fatalf("fetchAndPoll(%q, %q, %q) = %d, %s; want status = %d",
 					testModulePath, test.fullPath, test.version, status, responseText, http.StatusOK)
@@ -144,9 +189,9 @@
 			ctx, cancel := context.WithTimeout(context.Background(), test.fetchTimeout)
 			defer cancel()
 
-			s, f, _, teardown := newTestServerWithFetch(t, testModulesForProxy, nil)
+			_, f, _, teardown := newTestServerWithFetch(t, testModulesForProxy, nil)
 			defer teardown()
-			got, err := f.fetchAndPoll(ctx, s.getDataSource(ctx), test.modulePath, test.fullPath, test.version)
+			got, err := f.fetchAndPoll(ctx, testDB, test.modulePath, test.fullPath, test.version)
 
 			if got != test.want {
 				t.Fatalf("fetchAndPoll(ctx, testDB, q, %q, %q, %q): %d; want = %d",
@@ -181,9 +226,9 @@
 				t.Fatal(err)
 			}
 
-			s, f, _, teardown := newTestServerWithFetch(t, testModulesForProxy, nil)
+			_, f, _, teardown := newTestServerWithFetch(t, testModulesForProxy, nil)
 			defer teardown()
-			got, _ := f.fetchAndPoll(ctx, s.getDataSource(ctx), sample.ModulePath, sample.PackagePath, sample.VersionString)
+			got, _ := f.fetchAndPoll(ctx, testDB, sample.ModulePath, sample.PackagePath, sample.VersionString)
 			if got != test.want {
 				t.Fatalf("fetchAndPoll for status %d: %d; want = %d)", test.status, got, test.want)
 			}
diff --git a/internal/frontend/fetchserver/frontend_test.go b/internal/frontend/fetchserver/frontend_test.go
new file mode 100644
index 0000000..ab13c12
--- /dev/null
+++ b/internal/frontend/fetchserver/frontend_test.go
@@ -0,0 +1,83 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package fetchserver
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"golang.org/x/pkgsite/internal"
+	"golang.org/x/pkgsite/internal/postgres"
+	"golang.org/x/pkgsite/internal/source"
+	"golang.org/x/pkgsite/internal/testing/sample"
+)
+
+const testTimeout = 5 * time.Second
+
+var testDB *postgres.DB
+
+func TestMain(m *testing.M) {
+	postgres.RunDBTests("discovery_fetchserver_test", m, &testDB)
+}
+
+type testModule struct {
+	path            string
+	redistributable bool
+	versions        []string
+	packages        []testPackage
+}
+
+type testPackage struct {
+	name           string
+	suffix         string
+	readmeContents string
+	readmeFilePath string
+	docs           []*internal.Documentation
+}
+
+func insertTestModules(ctx context.Context, t *testing.T, mods []testModule) {
+	for _, mod := range mods {
+		var (
+			suffixes []string
+			pkgs     = make(map[string]testPackage)
+		)
+		for _, pkg := range mod.packages {
+			suffixes = append(suffixes, pkg.suffix)
+			pkgs[pkg.suffix] = pkg
+		}
+		for _, ver := range mod.versions {
+			m := sample.Module(mod.path, ver, suffixes...)
+			m.SourceInfo = source.NewGitHubInfo(sample.RepositoryURL, "", ver)
+			m.IsRedistributable = mod.redistributable
+			if !m.IsRedistributable {
+				m.Licenses = nil
+			}
+			for _, u := range m.Units {
+				if pkg, ok := pkgs[internal.Suffix(u.Path, m.ModulePath)]; ok {
+					if pkg.name != "" {
+						u.Name = pkg.name
+					}
+					if pkg.readmeContents != "" {
+						u.Readme = &internal.Readme{
+							Contents: pkg.readmeContents,
+							Filepath: pkg.readmeFilePath,
+						}
+					}
+					if pkg.docs != nil {
+						u.Documentation = pkg.docs
+					}
+				}
+				if !mod.redistributable {
+					u.IsRedistributable = false
+					u.Licenses = nil
+					u.Documentation = nil
+					u.Readme = nil
+				}
+			}
+			postgres.MustInsertModule(ctx, t, testDB, m)
+		}
+	}
+}
diff --git a/internal/frontend/server_test.go b/internal/frontend/fetchserver/server_test.go
similarity index 86%
rename from internal/frontend/server_test.go
rename to internal/frontend/fetchserver/server_test.go
index f566ab0..69c998d 100644
--- a/internal/frontend/server_test.go
+++ b/internal/frontend/fetchserver/server_test.go
@@ -2,12 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-// github.com/alicebob/miniredis/v2 pulls in
-// github.com/yuin/gopher-lua which uses a non
-// build-tag-guarded use of the syscall package.
-//go:build !plan9
-
-package frontend
+package fetchserver
 
 import (
 	"context"
@@ -17,31 +12,17 @@
 	"net/http/httptest"
 	"os"
 	"regexp"
-	"strings"
 	"testing"
 	"time"
 
-	"github.com/google/safehtml/template"
-	"github.com/jba/templatecheck"
 	"golang.org/x/net/html"
 	"golang.org/x/pkgsite/internal"
-	"golang.org/x/pkgsite/internal/frontend/page"
 	"golang.org/x/pkgsite/internal/postgres"
 	"golang.org/x/pkgsite/internal/testing/htmlcheck"
 	"golang.org/x/pkgsite/internal/testing/pagecheck"
 	"golang.org/x/pkgsite/internal/testing/sample"
-	"golang.org/x/pkgsite/static"
 )
 
-func TestHTMLInjection(t *testing.T) {
-	_, handler, _ := newTestServer(t, nil, nil)
-	w := httptest.NewRecorder()
-	handler.ServeHTTP(w, httptest.NewRequest("GET", "/<em>UHOH</em>", nil))
-	if strings.Contains(w.Body.String(), "<em>") {
-		t.Error("User input was rendered unescaped.")
-	}
-}
-
 const pseudoVersion = "v0.0.0-20140414041502-123456789012"
 
 type serverTestCase struct {
@@ -1169,160 +1150,16 @@
 	return c(doc)
 }
 
-func mustRequest(urlPath string, t *testing.T) *http.Request {
-	t.Helper()
-	r, err := http.NewRequest(http.MethodGet, "http://localhost"+urlPath, nil)
-	if err != nil {
-		t.Fatal(err)
+// absoluteTime takes a date and returns a human-readable,
+// date with the format mmm d, yyyy.
+// This is a copy of internal/frontend.absoluteTime.
+func absoluteTime(date time.Time) string {
+	if date.IsZero() {
+		return "unknown"
 	}
-	return r
-}
-
-func TestDetailsTTL(t *testing.T) {
-	tests := []struct {
-		r    *http.Request
-		want time.Duration
-	}{
-		{mustRequest("/host.com/module@v1.2.3/suffix", t), longTTL},
-		{mustRequest("/host.com/module/suffix", t), shortTTL},
-		{mustRequest("/host.com/module@v1.2.3/suffix?tab=overview", t), longTTL},
-		{mustRequest("/host.com/module@v1.2.3/suffix?tab=versions", t), defaultTTL},
-		{mustRequest("/host.com/module@v1.2.3/suffix?tab=importedby", t), defaultTTL},
-		{
-			func() *http.Request {
-				r := mustRequest("/host.com/module@v1.2.3/suffix?tab=overview", t)
-				r.Header.Set("user-agent",
-					"Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)")
-				return r
-			}(),
-			tinyTTL,
-		},
-	}
-	for _, test := range tests {
-		if got := detailsTTL(test.r); got != test.want {
-			t.Errorf("detailsTTL(%v) = %v, want %v", test.r, got, test.want)
-		}
-	}
-}
-
-func TestTagRoute(t *testing.T) {
-	mustRequest := func(url string) *http.Request {
-		req, err := http.NewRequest("GET", url, nil)
-		if err != nil {
-			t.Fatal(err)
-		}
-		return req
-	}
-	tests := []struct {
-		route string
-		req   *http.Request
-		want  string
-	}{
-		{"/pkg", mustRequest("http://localhost/pkg/foo?tab=versions"), "pkg-versions"},
-		{"/", mustRequest("http://localhost/foo?tab=imports"), "imports"},
-		{"/search", mustRequest("http://localhost/search?q=net&m=vuln"), "search-vuln"},
-		{"/search", mustRequest("http://localhost/search?q=net&m=package"), "search-package"},
-		{"/search", mustRequest("http://localhost/search?q=net&m=symbol"), "search-symbol"},
-		{"/search", mustRequest("http://localhost/search?q=net"), "search-package"},
-	}
-	for _, test := range tests {
-		t.Run(test.want, func(t *testing.T) {
-			if got := TagRoute(test.route, test.req); got != test.want {
-				t.Errorf("TagRoute(%q, %v) = %q, want %q", test.route, test.req, got, test.want)
-			}
-		})
-	}
-}
-
-func TestCheckTemplates(t *testing.T) {
-	// Perform additional checks on parsed templates.
-	staticFS := template.TrustedFSFromEmbed(static.FS)
-	templates, err := parsePageTemplates(staticFS)
-	if err != nil {
-		t.Fatal(err)
-	}
-	for _, c := range []struct {
-		name    string
-		subs    []string
-		typeval any
-	}{
-		{"badge", nil, badgePage{}},
-		// error.tmpl omitted because relies on an associated "message" template
-		// that's parsed on demand; see renderErrorPage above.
-		{"fetch", nil, page.ErrorPage{}},
-		{"homepage", nil, homepage{}},
-		{"license-policy", nil, licensePolicyPage{}},
-		{"search", nil, SearchPage{}},
-		{"search-help", nil, page.BasePage{}},
-		{"unit/main", nil, UnitPage{}},
-		{
-			"unit/main",
-			[]string{"unit-outline", "unit-readme", "unit-doc", "unit-files", "unit-directories"},
-			MainDetails{},
-		},
-		{"unit/importedby", nil, UnitPage{}},
-		{"unit/importedby", []string{"importedby"}, ImportedByDetails{}},
-		{"unit/imports", nil, UnitPage{}},
-		{"unit/imports", []string{"imports"}, ImportsDetails{}},
-		{"unit/licenses", nil, UnitPage{}},
-		{"unit/licenses", []string{"licenses"}, LicensesDetails{}},
-		{"unit/versions", nil, UnitPage{}},
-		{"unit/versions", []string{"versions"}, VersionsDetails{}},
-		{"vuln", nil, page.BasePage{}},
-		{"vuln/list", nil, VulnListPage{}},
-		{"vuln/entry", nil, VulnEntryPage{}},
-	} {
-		t.Run(c.name, func(t *testing.T) {
-			tm := templates[c.name]
-			if tm == nil {
-				t.Fatalf("no template %q", c.name)
-			}
-			if c.subs == nil {
-				if err := templatecheck.CheckSafe(tm, c.typeval); err != nil {
-					t.Fatal(err)
-				}
-			} else {
-				for _, n := range c.subs {
-					s := tm.Lookup(n)
-					if s == nil {
-						t.Fatalf("no sub-template %q of %q", n, c.name)
-					}
-					if err := templatecheck.CheckSafe(s, c.typeval); err != nil {
-						t.Fatalf("%s: %v", n, err)
-					}
-				}
-			}
-		})
-	}
-}
-
-func TestStripScheme(t *testing.T) {
-	for _, test := range []struct {
-		url, want string
-	}{
-		{"http://github.com", "github.com"},
-		{"https://github.com/path/to/something", "github.com/path/to/something"},
-		{"example.com", "example.com"},
-		{"chrome-extension://abcd", "abcd"},
-		{"nonwellformed.com/path?://query=1", "query=1"},
-	} {
-		if got := stripScheme(test.url); got != test.want {
-			t.Errorf("%q: got %q, want %q", test.url, got, test.want)
-		}
-	}
-}
-
-func TestInstallFS(t *testing.T) {
-	s, handler, teardown := newTestServer(t, nil, nil)
-	defer teardown()
-	s.InstallFS("/dir", os.DirFS("."))
-	// Request this file.
-	w := httptest.NewRecorder()
-	handler.ServeHTTP(w, httptest.NewRequest("GET", "/files/dir/server_test.go", nil))
-	if w.Code != http.StatusOK {
-		t.Errorf("got status code = %d, want %d", w.Code, http.StatusOK)
-	}
-	if want := "TestInstallFS"; !strings.Contains(w.Body.String(), want) {
-		t.Errorf("body does not contain %q", want)
-	}
+	// Convert to UTC because that is how the date is represented in the DB.
+	// (The pgx driver returns local times.) Example: if a date is stored
+	// as Jan 30 at midnight, then the local NYC time is on Jan 29, and this
+	// function would return "Jan 29" instead of the correct "Jan 30".
+	return date.In(time.UTC).Format("Jan _2, 2006")
 }
diff --git a/internal/frontend/frontend_test.go b/internal/frontend/frontend_test.go
index 0c2c411..1bb7e25 100644
--- a/internal/frontend/frontend_test.go
+++ b/internal/frontend/frontend_test.go
@@ -7,16 +7,19 @@
 import (
 	"context"
 	"net/http"
+	"net/http/httptest"
+	"os"
+	"strings"
 	"testing"
 	"time"
 
 	"github.com/google/safehtml/template"
+	"github.com/jba/templatecheck"
 	"golang.org/x/pkgsite/internal"
+	"golang.org/x/pkgsite/internal/frontend/page"
+	"golang.org/x/pkgsite/internal/frontend/versions"
 	"golang.org/x/pkgsite/internal/postgres"
 	"golang.org/x/pkgsite/internal/proxy/proxytest"
-	"golang.org/x/pkgsite/internal/queue"
-	"golang.org/x/pkgsite/internal/source"
-	"golang.org/x/pkgsite/internal/testing/sample"
 	"golang.org/x/pkgsite/static"
 	thirdparty "golang.org/x/pkgsite/third_party"
 )
@@ -67,85 +70,169 @@
 	}
 }
 
-func newTestServerWithFetch(t *testing.T, proxyModules []*proxytest.Module, cacher Cacher) (*Server, *FetchServer, http.Handler, func()) {
-	t.Helper()
-	proxyClient, teardown := proxytest.SetupTestClient(t, proxyModules)
-	sourceClient := source.NewClient(sourceTimeout)
-	ctx := context.Background()
-
-	q := queue.NewInMemory(ctx, 1, nil,
-		func(ctx context.Context, mpath, version string) (int, error) {
-			return FetchAndUpdateState(ctx, mpath, version, proxyClient, sourceClient, testDB)
-		})
-
-	f := &FetchServer{
-		Queue:                q,
-		TaskIDChangeInterval: 10 * time.Minute,
-	}
-
-	s, err := NewServer(ServerConfig{
-		FetchServer:      f,
-		DataSourceGetter: func(context.Context) internal.DataSource { return testDB },
-		Queue:            q,
-		TemplateFS:       template.TrustedFSFromEmbed(static.FS),
-		// Use the embedded FSs here to make sure they're tested.
-		// Integration tests will use the actual directories.
-		StaticFS:     static.FS,
-		ThirdPartyFS: thirdparty.FS,
-		StaticPath:   "../../static",
-	})
-	if err != nil {
-		t.Fatal(err)
-	}
-	mux := http.NewServeMux()
-	s.Install(mux.Handle, cacher, nil)
-
-	return s, f, mux, func() {
-		teardown()
-		postgres.ResetTestDB(testDB, t)
+func TestHTMLInjection(t *testing.T) {
+	_, handler, _ := newTestServer(t, nil, nil)
+	w := httptest.NewRecorder()
+	handler.ServeHTTP(w, httptest.NewRequest("GET", "/<em>UHOH</em>", nil))
+	if strings.Contains(w.Body.String(), "<em>") {
+		t.Error("User input was rendered unescaped.")
 	}
 }
 
-func insertTestModules(ctx context.Context, t *testing.T, mods []testModule) {
-	for _, mod := range mods {
-		var (
-			suffixes []string
-			pkgs     = make(map[string]testPackage)
-		)
-		for _, pkg := range mod.packages {
-			suffixes = append(suffixes, pkg.suffix)
-			pkgs[pkg.suffix] = pkg
+func mustRequest(urlPath string, t *testing.T) *http.Request {
+	t.Helper()
+	r, err := http.NewRequest(http.MethodGet, "http://localhost"+urlPath, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return r
+}
+
+func TestDetailsTTL(t *testing.T) {
+	tests := []struct {
+		r    *http.Request
+		want time.Duration
+	}{
+		{mustRequest("/host.com/module@v1.2.3/suffix", t), longTTL},
+		{mustRequest("/host.com/module/suffix", t), shortTTL},
+		{mustRequest("/host.com/module@v1.2.3/suffix?tab=overview", t), longTTL},
+		{mustRequest("/host.com/module@v1.2.3/suffix?tab=versions", t), defaultTTL},
+		{mustRequest("/host.com/module@v1.2.3/suffix?tab=importedby", t), defaultTTL},
+		{
+			func() *http.Request {
+				r := mustRequest("/host.com/module@v1.2.3/suffix?tab=overview", t)
+				r.Header.Set("user-agent",
+					"Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)")
+				return r
+			}(),
+			tinyTTL,
+		},
+	}
+	for _, test := range tests {
+		if got := detailsTTL(test.r); got != test.want {
+			t.Errorf("detailsTTL(%v) = %v, want %v", test.r, got, test.want)
 		}
-		for _, ver := range mod.versions {
-			m := sample.Module(mod.path, ver, suffixes...)
-			m.SourceInfo = source.NewGitHubInfo(sample.RepositoryURL, "", ver)
-			m.IsRedistributable = mod.redistributable
-			if !m.IsRedistributable {
-				m.Licenses = nil
+	}
+}
+
+func TestTagRoute(t *testing.T) {
+	mustRequest := func(url string) *http.Request {
+		req, err := http.NewRequest("GET", url, nil)
+		if err != nil {
+			t.Fatal(err)
+		}
+		return req
+	}
+	tests := []struct {
+		route string
+		req   *http.Request
+		want  string
+	}{
+		{"/pkg", mustRequest("http://localhost/pkg/foo?tab=versions"), "pkg-versions"},
+		{"/", mustRequest("http://localhost/foo?tab=imports"), "imports"},
+		{"/search", mustRequest("http://localhost/search?q=net&m=vuln"), "search-vuln"},
+		{"/search", mustRequest("http://localhost/search?q=net&m=package"), "search-package"},
+		{"/search", mustRequest("http://localhost/search?q=net&m=symbol"), "search-symbol"},
+		{"/search", mustRequest("http://localhost/search?q=net"), "search-package"},
+	}
+	for _, test := range tests {
+		t.Run(test.want, func(t *testing.T) {
+			if got := TagRoute(test.route, test.req); got != test.want {
+				t.Errorf("TagRoute(%q, %v) = %q, want %q", test.route, test.req, got, test.want)
 			}
-			for _, u := range m.Units {
-				if pkg, ok := pkgs[internal.Suffix(u.Path, m.ModulePath)]; ok {
-					if pkg.name != "" {
-						u.Name = pkg.name
+		})
+	}
+}
+
+func TestCheckTemplates(t *testing.T) {
+	// Perform additional checks on parsed templates.
+	staticFS := template.TrustedFSFromEmbed(static.FS)
+	templates, err := parsePageTemplates(staticFS)
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, c := range []struct {
+		name    string
+		subs    []string
+		typeval any
+	}{
+		{"badge", nil, badgePage{}},
+		// error.tmpl omitted because relies on an associated "message" template
+		// that's parsed on demand; see renderErrorPage above.
+		{"fetch", nil, page.ErrorPage{}},
+		{"homepage", nil, homepage{}},
+		{"license-policy", nil, licensePolicyPage{}},
+		{"search", nil, SearchPage{}},
+		{"search-help", nil, page.BasePage{}},
+		{"unit/main", nil, UnitPage{}},
+		{
+			"unit/main",
+			[]string{"unit-outline", "unit-readme", "unit-doc", "unit-files", "unit-directories"},
+			MainDetails{},
+		},
+		{"unit/importedby", nil, UnitPage{}},
+		{"unit/importedby", []string{"importedby"}, ImportedByDetails{}},
+		{"unit/imports", nil, UnitPage{}},
+		{"unit/imports", []string{"imports"}, ImportsDetails{}},
+		{"unit/licenses", nil, UnitPage{}},
+		{"unit/licenses", []string{"licenses"}, LicensesDetails{}},
+		{"unit/versions", nil, UnitPage{}},
+		{"unit/versions", []string{"versions"}, versions.VersionsDetails{}},
+		{"vuln", nil, page.BasePage{}},
+		{"vuln/list", nil, VulnListPage{}},
+		{"vuln/entry", nil, VulnEntryPage{}},
+	} {
+		t.Run(c.name, func(t *testing.T) {
+			tm := templates[c.name]
+			if tm == nil {
+				t.Fatalf("no template %q", c.name)
+			}
+			if c.subs == nil {
+				if err := templatecheck.CheckSafe(tm, c.typeval); err != nil {
+					t.Fatal(err)
+				}
+			} else {
+				for _, n := range c.subs {
+					s := tm.Lookup(n)
+					if s == nil {
+						t.Fatalf("no sub-template %q of %q", n, c.name)
 					}
-					if pkg.readmeContents != "" {
-						u.Readme = &internal.Readme{
-							Contents: pkg.readmeContents,
-							Filepath: pkg.readmeFilePath,
-						}
-					}
-					if pkg.docs != nil {
-						u.Documentation = pkg.docs
+					if err := templatecheck.CheckSafe(s, c.typeval); err != nil {
+						t.Fatalf("%s: %v", n, err)
 					}
 				}
-				if !mod.redistributable {
-					u.IsRedistributable = false
-					u.Licenses = nil
-					u.Documentation = nil
-					u.Readme = nil
-				}
 			}
-			postgres.MustInsertModule(ctx, t, testDB, m)
+		})
+	}
+}
+
+func TestStripScheme(t *testing.T) {
+	for _, test := range []struct {
+		url, want string
+	}{
+		{"http://github.com", "github.com"},
+		{"https://github.com/path/to/something", "github.com/path/to/something"},
+		{"example.com", "example.com"},
+		{"chrome-extension://abcd", "abcd"},
+		{"nonwellformed.com/path?://query=1", "query=1"},
+	} {
+		if got := stripScheme(test.url); got != test.want {
+			t.Errorf("%q: got %q, want %q", test.url, got, test.want)
 		}
 	}
 }
+
+func TestInstallFS(t *testing.T) {
+	s, handler, teardown := newTestServer(t, nil, nil)
+	defer teardown()
+	s.InstallFS("/dir", os.DirFS("."))
+	// Request this file.
+	w := httptest.NewRecorder()
+	handler.ServeHTTP(w, httptest.NewRequest("GET", "/files/dir/frontend_test.go", nil))
+	if w.Code != http.StatusOK {
+		t.Errorf("got status code = %d, want %d", w.Code, http.StatusOK)
+	}
+	if want := "TestInstallFS"; !strings.Contains(w.Body.String(), want) {
+		t.Errorf("body does not contain %q", want)
+	}
+}
diff --git a/internal/frontend/imports.go b/internal/frontend/imports.go
index 19af68e..2f8f163 100644
--- a/internal/frontend/imports.go
+++ b/internal/frontend/imports.go
@@ -9,6 +9,7 @@
 	"strings"
 
 	"golang.org/x/pkgsite/internal"
+	"golang.org/x/pkgsite/internal/frontend/serrors"
 	"golang.org/x/pkgsite/internal/log"
 	"golang.org/x/pkgsite/internal/stdlib"
 	"golang.org/x/text/language"
@@ -97,7 +98,7 @@
 	db, ok := ds.(internal.PostgresDB)
 	if !ok {
 		// The proxydatasource does not support the imported by page.
-		return nil, datasourceNotSupportedErr()
+		return nil, serrors.DatasourceNotSupportedError()
 	}
 
 	importedBy, err := db.GetImportedBy(ctx, pkgPath, modulePath, importedByLimit)
diff --git a/internal/frontend/latest_version.go b/internal/frontend/latest_version.go
index a98e61c..8af58fd 100644
--- a/internal/frontend/latest_version.go
+++ b/internal/frontend/latest_version.go
@@ -8,6 +8,7 @@
 	"context"
 
 	"golang.org/x/pkgsite/internal"
+	"golang.org/x/pkgsite/internal/frontend/versions"
 	"golang.org/x/pkgsite/internal/log"
 	"golang.org/x/pkgsite/internal/middleware/stats"
 )
@@ -31,7 +32,7 @@
 	if err != nil {
 		log.Errorf(ctx, "Server.GetLatestInfo: %v", err)
 	} else {
-		latest.MinorVersion = linkVersion(latest.MinorModulePath, latest.MinorVersion, latest.MinorVersion)
+		latest.MinorVersion = versions.LinkVersion(latest.MinorModulePath, latest.MinorVersion, latest.MinorVersion)
 	}
 	return latest
 }
diff --git a/internal/frontend/latest_version_test.go b/internal/frontend/latest_version_test.go
index 4382938..f3c9301 100644
--- a/internal/frontend/latest_version_test.go
+++ b/internal/frontend/latest_version_test.go
@@ -11,6 +11,7 @@
 
 	"golang.org/x/pkgsite/internal"
 	"golang.org/x/pkgsite/internal/postgres"
+	"golang.org/x/pkgsite/internal/source"
 	"golang.org/x/pkgsite/internal/testing/sample"
 )
 
@@ -68,3 +69,47 @@
 		})
 	}
 }
+
+func insertTestModules(ctx context.Context, t *testing.T, mods []testModule) {
+	for _, mod := range mods {
+		var (
+			suffixes []string
+			pkgs     = make(map[string]testPackage)
+		)
+		for _, pkg := range mod.packages {
+			suffixes = append(suffixes, pkg.suffix)
+			pkgs[pkg.suffix] = pkg
+		}
+		for _, ver := range mod.versions {
+			m := sample.Module(mod.path, ver, suffixes...)
+			m.SourceInfo = source.NewGitHubInfo(sample.RepositoryURL, "", ver)
+			m.IsRedistributable = mod.redistributable
+			if !m.IsRedistributable {
+				m.Licenses = nil
+			}
+			for _, u := range m.Units {
+				if pkg, ok := pkgs[internal.Suffix(u.Path, m.ModulePath)]; ok {
+					if pkg.name != "" {
+						u.Name = pkg.name
+					}
+					if pkg.readmeContents != "" {
+						u.Readme = &internal.Readme{
+							Contents: pkg.readmeContents,
+							Filepath: pkg.readmeFilePath,
+						}
+					}
+					if pkg.docs != nil {
+						u.Documentation = pkg.docs
+					}
+				}
+				if !mod.redistributable {
+					u.IsRedistributable = false
+					u.Licenses = nil
+					u.Documentation = nil
+					u.Readme = nil
+				}
+			}
+			postgres.MustInsertModule(ctx, t, testDB, m)
+		}
+	}
+}
diff --git a/internal/frontend/main.go b/internal/frontend/main.go
index 5a0e519..32a4dc8 100644
--- a/internal/frontend/main.go
+++ b/internal/frontend/main.go
@@ -13,6 +13,7 @@
 	"golang.org/x/mod/semver"
 	"golang.org/x/pkgsite/internal"
 	"golang.org/x/pkgsite/internal/derrors"
+	"golang.org/x/pkgsite/internal/frontend/serrors"
 	"golang.org/x/pkgsite/internal/godoc"
 	"golang.org/x/pkgsite/internal/godoc/dochtml"
 	"golang.org/x/pkgsite/internal/log"
@@ -155,7 +156,7 @@
 				// Instead of returning a 500, return a 404 so the user can
 				// reprocess the documentation.
 				log.Errorf(ctx, "fetchMainDetails(%q, %q, %q): %v", um.Path, um.ModulePath, um.Version, err)
-				return nil, errUnitNotFoundWithoutFetch
+				return nil, serrors.ErrUnitNotFoundWithoutFetch
 			}
 			return nil, err
 		}
diff --git a/internal/frontend/search.go b/internal/frontend/search.go
index 1e031e0..2ef9680 100644
--- a/internal/frontend/search.go
+++ b/internal/frontend/search.go
@@ -23,6 +23,7 @@
 	"golang.org/x/pkgsite/internal/derrors"
 	pagepkg "golang.org/x/pkgsite/internal/frontend/page"
 	"golang.org/x/pkgsite/internal/frontend/serrors"
+	"golang.org/x/pkgsite/internal/frontend/versions"
 	"golang.org/x/pkgsite/internal/log"
 	"golang.org/x/pkgsite/internal/stdlib"
 	"golang.org/x/pkgsite/internal/version"
@@ -66,7 +67,7 @@
 	searchSupport := ds.SearchSupport()
 	if searchSupport == internal.NoSearch {
 		// The proxydatasource does not support the imported by page.
-		return nil, datasourceNotSupportedErr()
+		return nil, serrors.DatasourceNotSupportedError()
 	}
 
 	ctx := r.Context()
@@ -308,7 +309,7 @@
 		Version:        r.Version,
 		ChipText:       chipText,
 		Synopsis:       r.Synopsis,
-		DisplayVersion: displayVersion(r.ModulePath, r.Version, r.Version),
+		DisplayVersion: versions.DisplayVersion(r.ModulePath, r.Version, r.Version),
 		Licenses:       r.Licenses,
 		CommitTime:     elapsedTime(r.CommitTime),
 		NumImportedBy:  pr.Sprint(r.NumImportedBy),
diff --git a/internal/frontend/serrors/serrors.go b/internal/frontend/serrors/serrors.go
index e2c08b9..b3c7cbd 100644
--- a/internal/frontend/serrors/serrors.go
+++ b/internal/frontend/serrors/serrors.go
@@ -9,6 +9,7 @@
 	"fmt"
 	"net/http"
 
+	"github.com/google/safehtml/template"
 	"golang.org/x/pkgsite/internal/frontend/page"
 )
 
@@ -27,3 +28,41 @@
 func (s *ServerError) Unwrap() error {
 	return s.Err
 }
+
+func DatasourceNotSupportedError() error {
+	return &ServerError{
+		Status: http.StatusFailedDependency,
+		Epage: &page.ErrorPage{
+			MessageTemplate: template.MakeTrustedTemplate(
+				`<h3 class="Error-message">This page is not supported by this datasource.</h3>`),
+		},
+	}
+}
+
+func InvalidVersionError(fullPath, requestedVersion string) error {
+	return &ServerError{
+		Status: http.StatusBadRequest,
+		Epage: &page.ErrorPage{
+			MessageTemplate: template.MakeTrustedTemplate(`
+					<h3 class="Error-message">{{.Version}} is not a valid semantic version.</h3>
+					<p class="Error-message">
+					  To search for packages like {{.Path}}, <a href="/search?q={{.Path}}">click here</a>.
+					</p>`),
+			MessageData: struct{ Path, Version string }{fullPath, requestedVersion},
+		},
+	}
+}
+
+// errUnitNotFoundWithoutFetch returns a 404 with instructions to the user on
+// how to manually fetch the package. No fetch button is provided. This is used
+// for very large modules or modules that previously 500ed.
+var ErrUnitNotFoundWithoutFetch = &ServerError{
+	Status: http.StatusNotFound,
+	Epage: &page.ErrorPage{
+		MessageTemplate: template.MakeTrustedTemplate(`
+					    <h3 class="Error-message">{{.StatusText}}</h3>
+					    <p class="Error-message">Check that you entered the URL correctly or try fetching it following the
+                        <a href="/about#adding-a-package">instructions here</a>.</p>`),
+		MessageData: struct{ StatusText string }{http.StatusText(http.StatusNotFound)},
+	},
+}
diff --git a/internal/frontend/tabs.go b/internal/frontend/tabs.go
index d7895dc..6e857bb 100644
--- a/internal/frontend/tabs.go
+++ b/internal/frontend/tabs.go
@@ -11,6 +11,7 @@
 
 	"golang.org/x/pkgsite/internal"
 	"golang.org/x/pkgsite/internal/derrors"
+	"golang.org/x/pkgsite/internal/frontend/versions"
 	"golang.org/x/pkgsite/internal/vuln"
 )
 
@@ -85,7 +86,7 @@
 		_, expandReadme := r.URL.Query()["readme"]
 		return fetchMainDetails(ctx, ds, um, requestedVersion, expandReadme, bc)
 	case tabVersions:
-		return fetchVersionsDetails(ctx, ds, um, vc)
+		return versions.FetchVersionsDetails(ctx, ds, um, vc)
 	case tabImports:
 		return fetchImportsDetails(ctx, ds, um.Path, um.ModulePath, um.Version)
 	case tabImportedBy:
diff --git a/internal/frontend/unit.go b/internal/frontend/unit.go
index c7e3eb5..16d4796 100644
--- a/internal/frontend/unit.go
+++ b/internal/frontend/unit.go
@@ -20,6 +20,7 @@
 	"golang.org/x/pkgsite/internal/frontend/page"
 	"golang.org/x/pkgsite/internal/frontend/serrors"
 	"golang.org/x/pkgsite/internal/frontend/urlinfo"
+	"golang.org/x/pkgsite/internal/frontend/versions"
 	"golang.org/x/pkgsite/internal/log"
 	"golang.org/x/pkgsite/internal/middleware/stats"
 	"golang.org/x/pkgsite/internal/stdlib"
@@ -129,7 +130,7 @@
 		}
 		db, ok := ds.(internal.PostgresDB)
 		if !ok || s.fetchServer == nil {
-			return datasourceNotSupportedErr()
+			return serrors.DatasourceNotSupportedError()
 		}
 		return s.fetchServer.ServePathNotFoundPage(w, r, db, info.FullPath, info.ModulePath, info.RequestedVersion)
 	}
@@ -207,18 +208,18 @@
 	if tabSettings.Name == "" {
 		basePage.UseResponsiveLayout = true
 	}
-	lv := linkVersion(um.ModulePath, info.RequestedVersion, um.Version)
+	lv := versions.LinkVersion(um.ModulePath, info.RequestedVersion, um.Version)
 	page := UnitPage{
 		BasePage:              basePage,
 		Unit:                  um,
 		Breadcrumb:            displayBreadcrumb(um, info.RequestedVersion),
 		Title:                 title,
 		SelectedTab:           tabSettings,
-		URLPath:               constructUnitURL(um.Path, um.ModulePath, info.RequestedVersion),
+		URLPath:               versions.ConstructUnitURL(um.Path, um.ModulePath, info.RequestedVersion),
 		CanonicalURLPath:      canonicalURLPath(um.Path, um.ModulePath, info.RequestedVersion, um.Version),
-		DisplayVersion:        displayVersion(um.ModulePath, info.RequestedVersion, um.Version),
+		DisplayVersion:        versions.DisplayVersion(um.ModulePath, info.RequestedVersion, um.Version),
 		LinkVersion:           lv,
-		LatestURL:             constructUnitURL(um.Path, um.ModulePath, version.Latest),
+		LatestURL:             versions.ConstructUnitURL(um.Path, um.ModulePath, version.Latest),
 		LatestMinorClass:      latestMinorClass(lv, latestInfo),
 		LatestMajorVersionURL: latestInfo.MajorUnitPath,
 		PageLabels:            pageLabels(um),
@@ -290,25 +291,11 @@
 	return true
 }
 
-// constructUnitURL returns a URL path that refers to the given unit at the requested
-// version. If requestedVersion is "latest", then the resulting path has no
-// version; otherwise, it has requestedVersion.
-func constructUnitURL(fullPath, modulePath, requestedVersion string) string {
-	if requestedVersion == version.Latest {
-		return "/" + fullPath
-	}
-	v := linkVersion(modulePath, requestedVersion, requestedVersion)
-	if fullPath == modulePath || modulePath == stdlib.ModulePath {
-		return fmt.Sprintf("/%s@%s", fullPath, v)
-	}
-	return fmt.Sprintf("/%s@%s/%s", modulePath, v, strings.TrimPrefix(fullPath, modulePath+"/"))
-}
-
 // canonicalURLPath constructs a URL path to the unit that always includes the
 // resolved version.
 func canonicalURLPath(fullPath, modulePath, requestedVersion, resolvedVersion string) string {
-	return constructUnitURL(fullPath, modulePath,
-		linkVersion(modulePath, requestedVersion, resolvedVersion))
+	return versions.ConstructUnitURL(fullPath, modulePath,
+		versions.LinkVersion(modulePath, requestedVersion, resolvedVersion))
 }
 
 func isGoProject(modulePath string) bool {
diff --git a/internal/frontend/unit_test.go b/internal/frontend/unit_test.go
index ae408cd..0f9db5e 100644
--- a/internal/frontend/unit_test.go
+++ b/internal/frontend/unit_test.go
@@ -8,6 +8,7 @@
 	"testing"
 
 	"golang.org/x/pkgsite/internal"
+	"golang.org/x/pkgsite/internal/frontend/versions"
 	"golang.org/x/pkgsite/internal/testing/sample"
 )
 
@@ -36,7 +37,7 @@
 			"/math@go1.2.3",
 		},
 	} {
-		got := constructUnitURL(test.path, test.modpath, test.version)
+		got := versions.ConstructUnitURL(test.path, test.modpath, test.version)
 		if got != test.want {
 			t.Errorf("unitURLPath(%q, %q, %q) = %q, want %q", test.path, test.modpath, test.version, got, test.want)
 		}
diff --git a/internal/frontend/symbol.go b/internal/frontend/versions/symbol.go
similarity index 99%
rename from internal/frontend/symbol.go
rename to internal/frontend/versions/symbol.go
index 25952dd..828e925 100644
--- a/internal/frontend/symbol.go
+++ b/internal/frontend/versions/symbol.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-package frontend
+package versions
 
 import (
 	"fmt"
diff --git a/internal/frontend/symbol_test.go b/internal/frontend/versions/symbol_test.go
similarity index 97%
rename from internal/frontend/symbol_test.go
rename to internal/frontend/versions/symbol_test.go
index c67c89f..0ce7236 100644
--- a/internal/frontend/symbol_test.go
+++ b/internal/frontend/versions/symbol_test.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-package frontend
+package versions
 
 import (
 	"testing"
diff --git a/internal/frontend/versions.go b/internal/frontend/versions/versions.go
similarity index 88%
rename from internal/frontend/versions.go
rename to internal/frontend/versions/versions.go
index 74f0960..3d630b6 100644
--- a/internal/frontend/versions.go
+++ b/internal/frontend/versions/versions.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-package frontend
+package versions
 
 import (
 	"context"
@@ -10,11 +10,13 @@
 	"path"
 	"sort"
 	"strings"
+	"time"
 	"unicode"
 
 	"golang.org/x/mod/semver"
 	"golang.org/x/pkgsite/internal"
 	"golang.org/x/pkgsite/internal/fetch"
+	"golang.org/x/pkgsite/internal/frontend/serrors"
 	"golang.org/x/pkgsite/internal/log"
 	"golang.org/x/pkgsite/internal/stdlib"
 	"golang.org/x/pkgsite/internal/version"
@@ -85,11 +87,11 @@
 	Vulns               []vuln.Vuln
 }
 
-func fetchVersionsDetails(ctx context.Context, ds internal.DataSource, um *internal.UnitMeta, vc *vuln.Client) (*VersionsDetails, error) {
+func FetchVersionsDetails(ctx context.Context, ds internal.DataSource, um *internal.UnitMeta, vc *vuln.Client) (*VersionsDetails, error) {
 	db, ok := ds.(internal.PostgresDB)
 	if !ok {
 		// The proxydatasource does not support the imported by page.
-		return nil, datasourceNotSupportedErr()
+		return nil, serrors.DatasourceNotSupportedError()
 	}
 	versions, err := db.GetVersionsForPath(ctx, um.Path)
 	if err != nil {
@@ -112,7 +114,7 @@
 		} else {
 			versionPath = pathInVersion(internal.V1Path(um.Path, um.ModulePath), mi)
 		}
-		return constructUnitURL(versionPath, mi.ModulePath, linkVersion(mi.ModulePath, mi.Version, mi.Version))
+		return ConstructUnitURL(versionPath, mi.ModulePath, LinkVersion(mi.ModulePath, mi.Version, mi.Version))
 	}
 	return buildVersionDetails(ctx, um.ModulePath, um.Path, versions, sh, linkify, vc)
 }
@@ -188,7 +190,7 @@
 		vs := &VersionSummary{
 			Link:                linkify(mi),
 			CommitTime:          commitTime,
-			Version:             linkVersion(mi.ModulePath, mi.Version, mi.Version),
+			Version:             LinkVersion(mi.ModulePath, mi.Version, mi.Version),
 			IsMinor:             isMinor(mi.Version),
 			Retracted:           mi.Retracted,
 			RetractionRationale: shortRationale(mi.RetractionRationale),
@@ -369,8 +371,8 @@
 	return v[j+1:]
 }
 
-// displayVersion returns the version string, formatted for display.
-func displayVersion(modulePath, requestedVersion, resolvedVersion string) string {
+// DisplayVersion returns the version string, formatted for display.
+func DisplayVersion(modulePath, requestedVersion, resolvedVersion string) string {
 	if modulePath == stdlib.ModulePath && resolvedVersion != fetch.LocalVersion {
 		if stdlib.SupportedBranches[requestedVersion] ||
 			(strings.HasPrefix(resolvedVersion, "v0.0.0") && resolvedVersion != "v0.0.0") { // Plain v0.0.0 is from the go packages module getter
@@ -391,10 +393,10 @@
 	return formatVersion(resolvedVersion)
 }
 
-// linkVersion returns the version string, suitable for use in
+// LinkVersion returns the version string, suitable for use in
 // a link to this site.
 // See TestLinkVersion for examples.
-func linkVersion(modulePath, requestedVersion, resolvedVersion string) string {
+func LinkVersion(modulePath, requestedVersion, resolvedVersion string) string {
 	if modulePath == stdlib.ModulePath && resolvedVersion != fetch.LocalVersion {
 		if strings.HasPrefix(resolvedVersion, "go") {
 			return resolvedVersion // already a go version
@@ -419,3 +421,32 @@
 	}
 	return tag
 }
+
+// absoluteTime takes a date and returns a human-readable,
+// date with the format mmm d, yyyy.
+// TODO(matloob): This is a copy of internal/frontend.absoluteTime.
+// Unify it with that function again.
+func absoluteTime(date time.Time) string {
+	if date.IsZero() {
+		return "unknown"
+	}
+	// Convert to UTC because that is how the date is represented in the DB.
+	// (The pgx driver returns local times.) Example: if a date is stored
+	// as Jan 30 at midnight, then the local NYC time is on Jan 29, and this
+	// function would return "Jan 29" instead of the correct "Jan 30".
+	return date.In(time.UTC).Format("Jan _2, 2006")
+}
+
+// ConstructUnitURL returns a URL path that refers to the given unit at the requested
+// version. If requestedVersion is "latest", then the resulting path has no
+// version; otherwise, it has requestedVersion.
+func ConstructUnitURL(fullPath, modulePath, requestedVersion string) string {
+	if requestedVersion == version.Latest {
+		return "/" + fullPath
+	}
+	v := LinkVersion(modulePath, requestedVersion, requestedVersion)
+	if fullPath == modulePath || modulePath == stdlib.ModulePath {
+		return fmt.Sprintf("/%s@%s", fullPath, v)
+	}
+	return fmt.Sprintf("/%s@%s/%s", modulePath, v, strings.TrimPrefix(fullPath, modulePath+"/"))
+}
diff --git a/internal/frontend/versions_test.go b/internal/frontend/versions/versions_test.go
similarity index 95%
rename from internal/frontend/versions_test.go
rename to internal/frontend/versions/versions_test.go
index 31efad6..e779357 100644
--- a/internal/frontend/versions_test.go
+++ b/internal/frontend/versions/versions_test.go
@@ -2,11 +2,12 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-package frontend
+package versions
 
 import (
 	"context"
 	"testing"
+	"time"
 
 	"github.com/google/go-cmp/cmp"
 	"golang.org/x/pkgsite/internal"
@@ -18,6 +19,14 @@
 	"golang.org/x/pkgsite/internal/vuln"
 )
 
+const testTimeout = 5 * time.Second
+
+var testDB *postgres.DB
+
+func TestMain(m *testing.M) {
+	postgres.RunDBTests("discovery_frontend_test", m, &testDB)
+}
+
 var (
 	modulePath1 = "test.com/module"
 	modulePath2 = "test.com/module/v2"
@@ -88,7 +97,7 @@
 		return &VersionList{
 			VersionListKey: VersionListKey{ModulePath: modulePath, Major: major, Incompatible: incompatible},
 			Versions: versionSummaries(pkgPath, versions, isStdlib, func(path, version string) string {
-				return constructUnitURL(pkgPath, modulePath, version)
+				return ConstructUnitURL(pkgPath, modulePath, version)
 			}),
 		}
 	}
@@ -225,9 +234,9 @@
 				postgres.MustInsertModule(ctx, t, testDB, v)
 			}
 
-			got, err := fetchVersionsDetails(ctx, testDB, &tc.pkg.UnitMeta, vc)
+			got, err := FetchVersionsDetails(ctx, testDB, &tc.pkg.UnitMeta, vc)
 			if err != nil {
-				t.Fatalf("fetchVersionsDetails(ctx, db, %q, %q): %v", tc.pkg.Path, tc.pkg.ModulePath, err)
+				t.Fatalf("FetchVersionsDetails(ctx, db, %q, %q): %v", tc.pkg.Path, tc.pkg.ModulePath, err)
 			}
 			for _, vl := range tc.wantDetails.ThisModule {
 				for _, v := range vl.Versions {
@@ -391,8 +400,8 @@
 		},
 	} {
 		t.Run(test.name, func(t *testing.T) {
-			if got := displayVersion(test.fullPath, test.requestedVersion, test.resolvedVersion); got != test.want {
-				t.Errorf("displayVersion(%q, %q, %q) = %q, want %q",
+			if got := DisplayVersion(test.fullPath, test.requestedVersion, test.resolvedVersion); got != test.want {
+				t.Errorf("DisplayVersion(%q, %q, %q) = %q, want %q",
 					test.fullPath, test.requestedVersion, test.resolvedVersion, got, test.want)
 			}
 		})
@@ -465,8 +474,8 @@
 		},
 	} {
 		t.Run(test.name, func(t *testing.T) {
-			if got := linkVersion(test.fullPath, test.requestedVersion, test.resolvedVersion); got != test.want {
-				t.Errorf("linkVersion(%q, %q, %q) = %q, want %q",
+			if got := LinkVersion(test.fullPath, test.requestedVersion, test.resolvedVersion); got != test.want {
+				t.Errorf("LinkVersion(%q, %q, %q) = %q, want %q",
 					test.fullPath, test.requestedVersion, test.resolvedVersion, got, test.want)
 			}
 		})
diff --git a/internal/frontend/vulns.go b/internal/frontend/vulns.go
index 93fe449..fa2b75f 100644
--- a/internal/frontend/vulns.go
+++ b/internal/frontend/vulns.go
@@ -43,7 +43,7 @@
 
 func (s *Server) serveVuln(w http.ResponseWriter, r *http.Request, _ internal.DataSource) error {
 	if s.vulnClient == nil {
-		return datasourceNotSupportedErr()
+		return serrors.DatasourceNotSupportedError()
 	}
 
 	vp, err := newVulnPage(r.Context(), r.URL, s.vulnClient)
diff --git a/internal/testing/integration/data_frontend_versions_test.go b/internal/testing/integration/data_frontend_versions_test.go
index b889c9d..7b3e2af 100644
--- a/internal/testing/integration/data_frontend_versions_test.go
+++ b/internal/testing/integration/data_frontend_versions_test.go
@@ -4,15 +4,17 @@
 
 package integration
 
-import "golang.org/x/pkgsite/internal/frontend"
+import (
+	"golang.org/x/pkgsite/internal/frontend/versions"
+)
 
-var versionsPageMultiGoosDuplicates = []*frontend.VersionList{
+var versionsPageMultiGoosDuplicates = []*versions.VersionList{
 	{
-		VersionListKey: frontend.VersionListKey{
+		VersionListKey: versions.VersionListKey{
 			ModulePath: "example.com/symbols",
 			Major:      "v1",
 		},
-		Versions: []*frontend.VersionSummary{
+		Versions: []*versions.VersionSummary{
 			{
 				CommitTime:          "Jan 30, 2019",
 				Link:                "/example.com/symbols@v1.2.0/duplicate",
@@ -20,7 +22,7 @@
 				RetractionRationale: "",
 				Version:             "v1.2.0",
 				IsMinor:             true,
-				Symbols: [][]*frontend.Symbol{
+				Symbols: [][]*versions.Symbol{
 					{
 						{
 							Name:     "TokenType",
@@ -53,7 +55,7 @@
 							New:      true,
 							Section:  "Types",
 							Kind:     "Type",
-							Children: []*frontend.Symbol{
+							Children: []*versions.Symbol{
 								{
 									Name:     "TokenShort",
 									Synopsis: "func TokenShort() TokenType",
@@ -75,7 +77,7 @@
 				RetractionRationale: "",
 				Version:             "v1.1.0",
 				IsMinor:             true,
-				Symbols: [][]*frontend.Symbol{
+				Symbols: [][]*versions.Symbol{
 					{
 						{
 							Name:     "TokenShort",
@@ -93,13 +95,13 @@
 	},
 }
 
-var versionsPageMultiGoos = []*frontend.VersionList{
+var versionsPageMultiGoos = []*versions.VersionList{
 	{
-		VersionListKey: frontend.VersionListKey{
+		VersionListKey: versions.VersionListKey{
 			ModulePath: "example.com/symbols",
 			Major:      "v1",
 		},
-		Versions: []*frontend.VersionSummary{
+		Versions: []*versions.VersionSummary{
 			{
 				CommitTime:          "Jan 30, 2019",
 				Link:                "/example.com/symbols@v1.2.0/multigoos",
@@ -107,7 +109,7 @@
 				RetractionRationale: "",
 				Version:             "v1.2.0",
 				IsMinor:             true,
-				Symbols: [][]*frontend.Symbol{
+				Symbols: [][]*versions.Symbol{
 					{
 						{
 							Name:     "CloseOnExec",
@@ -126,7 +128,7 @@
 							Section:  "Types",
 							Kind:     "Type",
 							Link:     "/example.com/symbols@v1.2.0/multigoos?GOOS=darwin#FD",
-							Children: []*frontend.Symbol{
+							Children: []*versions.Symbol{
 								{
 									Name:     "FD.MyMethod",
 									Synopsis: "func (*FD) MyMethod()",
@@ -144,7 +146,7 @@
 							Section:  "Types",
 							Kind:     "Type",
 							Link:     "/example.com/symbols@v1.2.0/multigoos?GOOS=windows#FD",
-							Children: []*frontend.Symbol{
+							Children: []*versions.Symbol{
 								{
 									Name:     "FD.MyWindowsMethod",
 									Synopsis: "func (*FD) MyWindowsMethod()",
@@ -166,7 +168,7 @@
 				RetractionRationale: "",
 				Version:             "v1.1.0",
 				IsMinor:             true,
-				Symbols: [][]*frontend.Symbol{
+				Symbols: [][]*versions.Symbol{
 					{
 						{
 							Name:     "CloseOnExec",
@@ -204,13 +206,13 @@
 	},
 }
 
-var versionsPageHello = []*frontend.VersionList{
+var versionsPageHello = []*versions.VersionList{
 	{
-		VersionListKey: frontend.VersionListKey{
+		VersionListKey: versions.VersionListKey{
 			ModulePath: "example.com/symbols",
 			Major:      "v1",
 		},
-		Versions: []*frontend.VersionSummary{
+		Versions: []*versions.VersionSummary{
 			{
 				CommitTime:          "Jan 30, 2019",
 				Link:                "/example.com/symbols@v1.2.0/hello",
@@ -218,7 +220,7 @@
 				RetractionRationale: "",
 				Version:             "v1.2.0",
 				IsMinor:             true,
-				Symbols: [][]*frontend.Symbol{
+				Symbols: [][]*versions.Symbol{
 					{
 						{
 							Name:     "Hello",
@@ -239,7 +241,7 @@
 				RetractionRationale: "",
 				Version:             "v1.1.0",
 				IsMinor:             true,
-				Symbols: [][]*frontend.Symbol{
+				Symbols: [][]*versions.Symbol{
 					{
 						{
 							Name:     "Hello",
@@ -266,13 +268,13 @@
 	},
 }
 
-var versionsPageSymbols = []*frontend.VersionList{
+var versionsPageSymbols = []*versions.VersionList{
 	{
-		VersionListKey: frontend.VersionListKey{
+		VersionListKey: versions.VersionListKey{
 			ModulePath: "example.com/symbols",
 			Major:      "v1",
 		},
-		Versions: []*frontend.VersionSummary{
+		Versions: []*versions.VersionSummary{
 			{
 				CommitTime: "Jan 30, 2019",
 				Link:       "/example.com/symbols@v1.2.0",
@@ -284,7 +286,7 @@
 				Link:       "/example.com/symbols@v1.1.0",
 				Version:    "v1.1.0",
 				IsMinor:    true,
-				Symbols: [][]*frontend.Symbol{
+				Symbols: [][]*versions.Symbol{
 					{
 						{
 							Name:     "I2",
@@ -292,7 +294,7 @@
 							Link:     "/example.com/symbols@v1.1.0#I2",
 							Section:  "Types",
 							Kind:     "Type",
-							Children: []*frontend.Symbol{
+							Children: []*versions.Symbol{
 								{
 									Name:     "I2.M2",
 									Synopsis: "M2 func()",
@@ -309,7 +311,7 @@
 							Link:     "/example.com/symbols@v1.1.0#S2",
 							Section:  "Types",
 							Kind:     "Type",
-							Children: []*frontend.Symbol{
+							Children: []*versions.Symbol{
 								{
 									Name:     "S2.G",
 									Synopsis: "G int",
@@ -338,7 +340,7 @@
 				RetractionRationale: "",
 				Version:             "v1.0.0",
 				IsMinor:             true,
-				Symbols: [][]*frontend.Symbol{
+				Symbols: [][]*versions.Symbol{
 					{
 						{
 							Name:     "AA",
@@ -435,7 +437,7 @@
 							New:      true,
 							Section:  "Types",
 							Kind:     "Type",
-							Children: []*frontend.Symbol{
+							Children: []*versions.Symbol{
 								{
 									Name:     "I1.M1",
 									Synopsis: "M1 func()",
@@ -469,7 +471,7 @@
 							New:      true,
 							Section:  "Types",
 							Kind:     "Type",
-							Children: []*frontend.Symbol{
+							Children: []*versions.Symbol{
 								{
 									Name:     "DD",
 									Synopsis: "const DD",
@@ -503,7 +505,7 @@
 							New:      true,
 							Section:  "Types",
 							Kind:     "Type",
-							Children: []*frontend.Symbol{
+							Children: []*versions.Symbol{
 								{
 									Name:     "S1.F",
 									Synopsis: "F int",
@@ -529,7 +531,7 @@
 							New:      true,
 							Section:  "Types",
 							Kind:     "Type",
-							Children: []*frontend.Symbol{
+							Children: []*versions.Symbol{
 								{
 									Name:     "CT",
 									Synopsis: "const CT",
diff --git a/internal/testing/integration/frontend_symbol_test.go b/internal/testing/integration/frontend_symbol_test.go
index acd2970..3080678 100644
--- a/internal/testing/integration/frontend_symbol_test.go
+++ b/internal/testing/integration/frontend_symbol_test.go
@@ -10,7 +10,7 @@
 	"fmt"
 	"testing"
 
-	"golang.org/x/pkgsite/internal/frontend"
+	"golang.org/x/pkgsite/internal/frontend/versions"
 	"golang.org/x/pkgsite/internal/postgres"
 	"golang.org/x/pkgsite/internal/symbol"
 )
@@ -49,11 +49,11 @@
 			// Get frontend data.
 			urlPath := fmt.Sprintf("/%s?tab=versions&content=json", test.pkgPath)
 			body := getFrontendPage(t, urlPath)
-			var vd frontend.VersionsDetails
+			var vd versions.VersionsDetails
 			if err := json.Unmarshal([]byte(body), &vd); err != nil {
 				t.Fatalf("json.Unmarshal: %v\n %s", err, body)
 			}
-			sh, err := frontend.ParseVersionsDetails(&vd)
+			sh, err := versions.ParseVersionsDetails(&vd)
 			if err != nil {
 				t.Fatal(err)
 			}
diff --git a/internal/testing/integration/frontend_test.go b/internal/testing/integration/frontend_test.go
index 4e75204..4b66063 100644
--- a/internal/testing/integration/frontend_test.go
+++ b/internal/testing/integration/frontend_test.go
@@ -19,6 +19,7 @@
 	"golang.org/x/pkgsite/internal/experiment"
 	"golang.org/x/pkgsite/internal/fetch"
 	"golang.org/x/pkgsite/internal/frontend"
+	"golang.org/x/pkgsite/internal/frontend/fetchserver"
 	"golang.org/x/pkgsite/internal/middleware"
 	"golang.org/x/pkgsite/internal/postgres"
 	"golang.org/x/pkgsite/internal/proxy"
@@ -33,7 +34,7 @@
 func setupFrontend(ctx context.Context, t *testing.T, q queue.Queue, rc *redis.Client) *httptest.Server {
 	t.Helper()
 	const staticDir = "../../../static"
-	fs := &frontend.FetchServer{
+	fs := &fetchserver.FetchServer{
 		Queue:                q,
 		TaskIDChangeInterval: 10 * time.Minute,
 	}
@@ -90,7 +91,7 @@
 	sourceClient := source.NewClient(1 * time.Second)
 	q := queue.NewInMemory(cctx, 1, experimentNames,
 		func(ctx context.Context, mpath, version string) (_ int, err error) {
-			return frontend.FetchAndUpdateState(ctx, mpath, version, proxyClient, sourceClient, testDB)
+			return fetchserver.FetchAndUpdateState(ctx, mpath, version, proxyClient, sourceClient, testDB)
 		})
 	return q, func() {
 		cancel()
diff --git a/internal/testing/integration/frontend_versions_test.go b/internal/testing/integration/frontend_versions_test.go
index 2b9f369..ec03d9b 100644
--- a/internal/testing/integration/frontend_versions_test.go
+++ b/internal/testing/integration/frontend_versions_test.go
@@ -12,7 +12,7 @@
 
 	"github.com/google/go-cmp/cmp"
 	"github.com/google/go-cmp/cmp/cmpopts"
-	"golang.org/x/pkgsite/internal/frontend"
+	"golang.org/x/pkgsite/internal/frontend/versions"
 	"golang.org/x/pkgsite/internal/postgres"
 )
 
@@ -24,7 +24,7 @@
 	const modulePath = "example.com/symbols"
 	for _, test := range []struct {
 		name, pkgPath string
-		want          []*frontend.VersionList
+		want          []*versions.VersionList
 	}{
 		{"versions page symbols - one version all symbols", modulePath, versionsPageSymbols},
 		{"versions page hello - multi GOOS", modulePath + "/hello", versionsPageHello},
@@ -35,11 +35,11 @@
 		t.Run(test.name, func(t *testing.T) {
 			urlPath := fmt.Sprintf("/%s?tab=versions&content=json", test.pkgPath)
 			body := getFrontendPage(t, urlPath)
-			var got frontend.VersionsDetails
+			var got versions.VersionsDetails
 			if err := json.Unmarshal([]byte(body), &got); err != nil {
 				t.Fatalf("json.Unmarshal: %v", err)
 			}
-			if diff := cmp.Diff(test.want, got.ThisModule, cmpopts.IgnoreUnexported(frontend.Symbol{})); diff != "" {
+			if diff := cmp.Diff(test.want, got.ThisModule, cmpopts.IgnoreUnexported(versions.Symbol{})); diff != "" {
 				t.Errorf("mismatch (-want, got):\n%s", diff)
 			}
 		})
diff --git a/tests/api/main.go b/tests/api/main.go
index 77040ac..111ad11 100755
--- a/tests/api/main.go
+++ b/tests/api/main.go
@@ -23,6 +23,7 @@
 	"golang.org/x/pkgsite/internal"
 	"golang.org/x/pkgsite/internal/derrors"
 	"golang.org/x/pkgsite/internal/frontend"
+	"golang.org/x/pkgsite/internal/frontend/versions"
 	"golang.org/x/pkgsite/internal/proxy"
 	"golang.org/x/pkgsite/internal/symbol"
 	"golang.org/x/pkgsite/internal/version"
@@ -196,7 +197,7 @@
 		return err
 	}
 
-	sh, err := frontend.ParseVersionsDetails(vd)
+	sh, err := versions.ParseVersionsDetails(vd)
 	if err != nil {
 		return err
 	}