refactor: Migrate from Next.js to pure webpack config (#360)

Fix for #348 - migrate our NextJS project to a pure webpack project w/ a single bundle

- [x] Switch from `next/link` to `react-router-dom`'s link 

> This part was easy - just change the import to `import { Link } from "react-router-dom"` and `<Link href={...} />` to `<Link to={...} />`

- [x] Switch from `next/router` to `react-router-dom`'s paradigms (`useNavigation`, `useLocation`, and `useParams`)

> `router.push` can be converted to `navigate(...)` (provided by the `useNavigate` hook)
> `router.replace` can be converted `navigate(..., {replace: true})` 
>  Query parameters (`const { query } = useRouter`) can be converted to `const query = useParams()`)

- [x] Implement client-side routing with `react-router-dom`

> Parameterized routes in NextJS like `projects/[organization]/[project]` would look like:
> ```
>               <Route path="projects">
>                    <Route path=":organization/:project">
>                    <Route index element={<ProjectPage />} />
>                  </Route>
>               </Route>
> ```

I've hooked up a `build:analyze` command that spins up a server to show the bundle size:
<img width="1303" alt="image" src="https://user-images.githubusercontent.com/88213859/157496889-87c5fdcd-fad1-4f2e-b7b6-437aebf99641.png">

The bundle looks OK, but there are some opportunities for improvement - the heavy-weight dependencies, like React, ReactDOM, Material-UI, and lodash could be brought in via a CDN: https://stackoverflow.com/questions/50645796/how-to-import-reactjs-material-ui-using-a-cdn-through-webpacks-externals
This commit is contained in:
Bryan 2022-03-12 12:51:05 -08:00 committed by GitHub
parent bf1f858c15
commit ec077c6191
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 1903 additions and 1302 deletions

View File

@ -352,6 +352,8 @@ jobs:
working-directory: site
- run: yarn playwright:test
env:
DEBUG: pw:api
working-directory: site
- name: Upload DataDog Trace

View File

@ -85,7 +85,6 @@ provisionersdk/proto: provisionersdk/proto/provisioner.proto
site/out:
./scripts/yarn_install.sh
cd site && yarn build
cd site && yarn export
# Restores GITKEEP files!
git checkout HEAD site/out
.PHONY: site/out

View File

@ -168,7 +168,7 @@ func New(options *Options) (http.Handler, func()) {
r.Get("/resources", api.workspaceBuildResources)
})
})
r.NotFound(site.Handler(options.Logger).ServeHTTP)
r.NotFound(site.DefaultHandler().ServeHTTP)
return r, api.websocketWaitGroup.Wait
}

14
site/Main.tsx Normal file
View File

@ -0,0 +1,14 @@
import React from "react"
import ReactDOM from "react-dom"
import { App } from "./app"
// This is the entry point for the app - where everything start.
// In the future, we'll likely bring in more bootstrapping logic -
// like: https://github.com/coder/m/blob/50898bd4803df7639bd181e484c74ac5d84da474/product/coder/site/pages/_app.tsx#L32
const main = () => {
const element = document.getElementById("root")
ReactDOM.render(<App />, element)
}
main()

View File

@ -1,10 +0,0 @@
/**
* Global setup for our Jest tests
*/
// Set up 'next-router-mock' to with our front-end tests:
// https://github.com/scottrippey/next-router-mock#quick-start
jest.mock("next/router", () => require("next-router-mock"))
// Suppress isolated modules warning
export {}

73
site/app.tsx Normal file
View File

@ -0,0 +1,73 @@
import React from "react"
import CssBaseline from "@material-ui/core/CssBaseline"
import ThemeProvider from "@material-ui/styles/ThemeProvider"
import { SWRConfig } from "swr"
import { UserProvider } from "./contexts/UserContext"
import { light } from "./theme"
import { BrowserRouter as Router, Route, Routes } from "react-router-dom"
import { CliAuthenticationPage } from "./pages/cli-auth"
import { NotFoundPage } from "./pages/404"
import { IndexPage } from "./pages/index"
import { SignInPage } from "./pages/login"
import { ProjectsPage } from "./pages/projects"
import { ProjectPage } from "./pages/projects/[organization]/[project]"
import { CreateWorkspacePage } from "./pages/projects/[organization]/[project]/create"
import { WorkspacePage } from "./pages/workspaces/[workspace]"
export const App: React.FC = () => {
return (
<Router>
<SWRConfig
value={{
// This code came from the SWR documentation:
// https://swr.vercel.app/docs/error-handling#status-code-and-error-object
fetcher: async (url: string) => {
const res = await fetch(url)
// By default, `fetch` won't treat 4xx or 5xx response as errors.
// However, we want SWR to treat these as errors - so if `res.ok` is false,
// we want to throw an error to bubble that up to SWR.
if (!res.ok) {
const err = new Error((await res.json()).error?.message || res.statusText)
throw err
}
return res.json()
},
}}
>
<UserProvider>
<ThemeProvider theme={light}>
<CssBaseline />
<Routes>
<Route path="/">
<Route index element={<IndexPage />} />
<Route path="login" element={<SignInPage />} />
<Route path="cli-auth" element={<CliAuthenticationPage />} />
<Route path="projects">
<Route index element={<ProjectsPage />} />
<Route path=":organization/:project">
<Route index element={<ProjectPage />} />
<Route path="create" element={<CreateWorkspacePage />} />
</Route>
</Route>
<Route path="workspaces">
<Route path=":workspace" element={<WorkspacePage />} />
</Route>
{/* Using path="*"" means "match anything", so this route
acts like a catch-all for URLs that we don't have explicit
routes for. */}
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</ThemeProvider>
</UserProvider>
</SWRConfig>
</Router>
)
}

View File

@ -1,7 +1,7 @@
import React from "react"
import Button from "@material-ui/core/Button"
import { makeStyles } from "@material-ui/core/styles"
import Link from "next/link"
import { Link } from "react-router-dom"
import { User } from "../../contexts/UserContext"
import { Logo } from "../Icons"
@ -17,7 +17,7 @@ export const Navbar: React.FC<NavbarProps> = ({ user, onSignOut }) => {
return (
<div className={styles.root}>
<div className={styles.fixed}>
<Link href="/">
<Link to="/">
<Button className={styles.logo} variant="text">
<Logo fill="white" opacity={1} />
</Button>

View File

@ -1,22 +0,0 @@
import { render, waitFor } from "@testing-library/react"
import singletonRouter from "next/router"
import mockRouter from "next-router-mock"
import React from "react"
import { Redirect } from "./Redirect"
describe("Redirect", () => {
// Reset the router to '/' before every test
beforeEach(() => {
mockRouter.setCurrentUrl("/")
})
it("performs client-side redirect on render", async () => {
// When
render(<Redirect to="/workspaces/v2" />)
// Then
await waitFor(() => {
expect(singletonRouter).toMatchObject({ asPath: "/workspaces/v2" })
})
})
})

View File

@ -1,23 +0,0 @@
import React, { useEffect } from "react"
import { useRouter } from "next/router"
export interface RedirectProps {
/**
* The path to redirect to
* @example '/projects'
*/
to: string
}
/**
* Helper component to perform a client-side redirect
*/
export const Redirect: React.FC<RedirectProps> = ({ to }) => {
const { replace } = useRouter()
useEffect(() => {
void replace(to)
}, [replace, to])
return null
}

View File

@ -1,13 +1,12 @@
import React from "react"
import singletonRouter from "next/router"
import mockRouter from "next-router-mock"
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { act, fireEvent, screen, waitFor } from "@testing-library/react"
import { history, render } from "../../test_helpers"
import { SignInForm } from "./SignInForm"
describe("SignInForm", () => {
beforeEach(() => {
mockRouter.setCurrentUrl("/login")
history.replace("/")
})
it("renders content", async () => {
@ -56,14 +55,14 @@ describe("SignInForm", () => {
// Then
// Should redirect because login was successful
await waitFor(() => expect(singletonRouter).toMatchObject({ asPath: "/" }))
await waitFor(() => expect(history.location.pathname).toEqual("/"))
})
it("respects ?redirect query parameter when complete", async () => {
// Given
const loginHandler = (_email: string, _password: string) => Promise.resolve()
// Set a path to redirect to after login is successful
mockRouter.setCurrentUrl("/login?redirect=%2Fsome%2Fother%2Fpath")
history.replace("/login?redirect=%2Fsome%2Fother%2Fpath")
// When
// Render the component
@ -78,6 +77,6 @@ describe("SignInForm", () => {
// Then
// Should redirect to /some/other/path because ?redirect was specified and login was successful
await waitFor(() => expect(singletonRouter).toMatchObject({ asPath: "/some/other/path" }))
await waitFor(() => expect(history.location.pathname).toEqual("/some/other/path"))
})
})

View File

@ -1,6 +1,7 @@
import { makeStyles } from "@material-ui/core/styles"
import { FormikContextType, useFormik } from "formik"
import { NextRouter, useRouter } from "next/router"
import { Location } from "history"
import { useNavigate, useLocation } from "react-router-dom"
import React from "react"
import { useSWRConfig } from "swr"
import * as Yup from "yup"
@ -9,7 +10,6 @@ import { Welcome } from "./Welcome"
import { FormTextField } from "../Form"
import * as API from "./../../api"
import { LoadingButton } from "./../Button"
import { firstOrItem } from "../../util/array"
/**
* BuiltInAuthFormValues describes a form using built-in (email/password)
@ -47,7 +47,8 @@ export interface SignInProps {
export const SignInForm: React.FC<SignInProps> = ({
loginHandler = (email: string, password: string) => API.login(email, password),
}) => {
const router = useRouter()
const navigate = useNavigate()
const location = useLocation()
const styles = useStyles()
const { mutate } = useSWRConfig()
@ -63,8 +64,8 @@ export const SignInForm: React.FC<SignInProps> = ({
// Tell SWR to invalidate the cache for the user endpoint
await mutate("/api/v2/users/me")
const redirect = getRedirectFromRouter(router)
await router.push(redirect)
const redirect = getRedirectFromLocation(location)
await navigate(redirect)
} catch (err) {
helpers.setFieldError("password", "The username or password is incorrect.")
}
@ -121,11 +122,10 @@ export const SignInForm: React.FC<SignInProps> = ({
)
}
const getRedirectFromRouter = (router: NextRouter) => {
const getRedirectFromLocation = (location: Location) => {
const defaultRedirect = "/"
if (router.query.redirect) {
return firstOrItem(router.query.redirect, defaultRedirect)
} else {
return defaultRedirect
}
const searchParams = new URLSearchParams(location.search)
const redirect = searchParams.get("redirect")
return redirect ? redirect : defaultRedirect
}

View File

@ -1,7 +1,7 @@
import { render, screen } from "@testing-library/react"
import { screen } from "@testing-library/react"
import React from "react"
import { Workspace } from "./Workspace"
import { MockOrganization, MockProject, MockWorkspace } from "../../test_helpers"
import { MockOrganization, MockProject, MockWorkspace, render } from "../../test_helpers"
describe("Workspace", () => {
it("renders", async () => {

View File

@ -3,7 +3,7 @@ import Paper from "@material-ui/core/Paper"
import Typography from "@material-ui/core/Typography"
import { makeStyles } from "@material-ui/core/styles"
import CloudCircleIcon from "@material-ui/icons/CloudCircle"
import Link from "next/link"
import { Link } from "react-router-dom"
import React from "react"
import * as Constants from "./constants"
import * as API from "../../api"
@ -68,7 +68,7 @@ export const WorkspaceHeader: React.FC<WorkspaceProps> = ({ organization, projec
<div className={styles.vertical}>
<Typography variant="h4">{workspace.name}</Typography>
<Typography variant="body2" color="textSecondary">
<Link href={projectLink}>{project.name}</Link>
<Link to={projectLink}>{project.name}</Link>
</Typography>
</div>
</div>

View File

@ -1,4 +1,3 @@
export * from "./Button"
export * from "./EmptyState"
export * from "./Page"
export * from "./Redirect"

View File

@ -1,11 +1,8 @@
import singletonRouter from "next/router"
import mockRouter from "next-router-mock"
import React from "react"
import { SWRConfig } from "swr"
import { render, screen, waitFor } from "@testing-library/react"
import { screen, waitFor } from "@testing-library/react"
import { User, UserProvider, useUser } from "./UserContext"
import { MockUser } from "../test_helpers"
import { history, MockUser, render } from "../test_helpers"
namespace Helpers {
// Helper component that renders out the state of the `useUser` hook.
@ -54,7 +51,7 @@ describe("UserContext", () => {
// Reset the router to '/' before every test
beforeEach(() => {
mockRouter.setCurrentUrl("/")
history.replace("/")
})
it("shouldn't redirect if user fails to load and redirectOnFailure is false", async () => {
@ -67,7 +64,8 @@ describe("UserContext", () => {
expect(screen.queryByText("Error:", { exact: false })).toBeDefined()
})
// ...and the route should be unchanged
expect(singletonRouter).toMatchObject({ asPath: "/" })
expect(history.location.pathname).toEqual("/")
expect(history.location.search).toEqual("")
})
it("should redirect if user fails to load and redirectOnFailure is true", async () => {
@ -76,7 +74,8 @@ describe("UserContext", () => {
// Then
// Verify we route to the login page
await waitFor(() => expect(singletonRouter).toMatchObject({ asPath: "/login?redirect=%2F" }))
await waitFor(() => expect(history.location.pathname).toEqual("/login"))
await waitFor(() => expect(history.location.search).toEqual("?redirect=%2F"))
})
it("should not redirect if user loads and redirectOnFailure is true", async () => {
@ -89,6 +88,7 @@ describe("UserContext", () => {
expect(screen.queryByText("Me:", { exact: false })).toBeDefined()
})
// ...and the route should be unchanged
expect(singletonRouter).toMatchObject({ asPath: "/" })
expect(history.location.pathname).toEqual("/")
expect(history.location.search).toEqual("")
})
})

View File

@ -1,4 +1,4 @@
import { useRouter } from "next/router"
import { useLocation, useNavigate } from "react-router-dom"
import React, { useContext, useEffect } from "react"
import useSWR from "swr"
@ -25,18 +25,15 @@ const UserContext = React.createContext<UserContext>({
export const useUser = (redirectOnError = false): UserContext => {
const ctx = useContext(UserContext)
const { push, asPath } = useRouter()
const navigate = useNavigate()
const { pathname } = useLocation()
const requestError = ctx.error
useEffect(() => {
if (redirectOnError && requestError) {
// 'void' means we are ignoring handling the promise returned
// from router.push (and lets the linter know we're OK with that!)
void push({
navigate({
pathname: "/login",
query: {
redirect: asPath,
},
search: "?redirect=" + encodeURIComponent(pathname),
})
}
// Disabling exhaustive deps here because it can cause an
@ -48,18 +45,17 @@ export const useUser = (redirectOnError = false): UserContext => {
}
export const UserProvider: React.FC = (props) => {
const router = useRouter()
const navigate = useNavigate()
const location = useLocation()
const { data, error, mutate } = useSWR("/api/v2/users/me")
const signOut = async () => {
await API.logout()
// Tell SWR to invalidate the cache for the user endpoint
await mutate("/api/v2/users/me")
await router.push({
navigate({
pathname: "/login",
query: {
redirect: router.asPath,
},
search: "?redirect=" + encodeURIComponent(location.pathname),
})
}

View File

@ -1,42 +0,0 @@
import express from "express"
import { createProxyMiddleware } from "http-proxy-middleware"
import next from "next"
const port = process.env.PORT || 8080
const dev = process.env.NODE_ENV !== "production"
let coderV2Host = "http://127.0.0.1:3000"
if (process.env.CODERV2_HOST) {
if (!/^http(s)?:\/\//.test(process.env.CODERV2_HOST)) {
throw new Error("CODERV2_HOST must be http(s)")
} else {
coderV2Host = process.env.CODERV2_HOST
}
}
console.log(`Using CODERV2_HOST: ${coderV2Host}`)
const app = next({ dev, dir: "." })
const handle = app.getRequestHandler()
app
.prepare()
.then(() => {
const server = express()
server.use(
"/api",
createProxyMiddleware("/api", {
target: coderV2Host,
ws: true,
secure: false,
changeOrigin: true,
}),
)
server.all("*", (req, res) => handle(req, res))
server.listen(port)
})
.catch((err) => {
console.error(err)
process.exit(1)
})

View File

@ -1,6 +1,7 @@
import { test } from "@playwright/test"
import { ProjectsPage, SignInPage } from "../pom"
import { email, password } from "../constants"
import { waitForClientSideNavigation } from "./../util"
test("Login takes user to /projects", async ({ baseURL, page }) => {
await page.goto(baseURL + "/", { waitUntil: "networkidle" })
@ -10,7 +11,7 @@ test("Login takes user to /projects", async ({ baseURL, page }) => {
await signInPage.submitBuiltInAuthentication(email, password)
const projectsPage = new ProjectsPage(baseURL, page)
await page.waitForNavigation({ url: projectsPage.url, waitUntil: "networkidle" })
await waitForClientSideNavigation(page, { to: projectsPage.url })
await page.waitForSelector("text=Projects")
})

82
site/e2e/util.ts Normal file
View File

@ -0,0 +1,82 @@
import { Page } from "@playwright/test"
/**
* `timeout(x)` is a helper function to create a promise that resolves after `x` milliseconds.
*
* @param timeoutInMilliseconds Time to wait for promise to resolve
* @returns `Promise`
*/
export const timeout = (timeoutInMilliseconds: number): Promise<void> => {
return new Promise((resolve) => {
setTimeout(resolve, timeoutInMilliseconds)
})
}
/**
* `waitFor(f, timeout?)` waits for a predicate to return `true`, running it periodically until it returns `true`.
*
* If `f` never returns `true`, the function will simply return. In other words, the burden is on the consumer
* to check that the predicate is passing (`waitFor` does no validation).
*
* @param f A predicate that returns a `Promise<boolean>`
* @param timeToWaitInMilliseconds The total time to wait for the condition to be `true`.
* @returns
*/
export const waitFor = async (f: () => Promise<boolean>, timeToWaitInMilliseconds = 30000): Promise<void> => {
let elapsedTime = 0
const timeToWaitPerIteration = 1000
while (elapsedTime < timeToWaitInMilliseconds) {
const condition = await f()
if (condition) {
return
}
await timeout(timeToWaitPerIteration)
elapsedTime += timeToWaitPerIteration
}
}
interface WaitForClientSideNavigationOpts {
/**
* from is the page before navigation (the 'current' page)
*/
from?: string
/**
* to is the page after navigation (the 'next' page)
*/
to?: string
}
/**
* waitForClientSideNavigation waits for the url to change from opts.from to
* opts.to (if specified), as well as a network idle load state. This enhances
* a native playwright check for navigation or loadstate.
*
* @remark This is necessary in a client-side SPA world since playwright
* waitForNavigation waits for load events on the DOM (ex: after a page load
* from the server).
*
* @todo Better logging for this.
*/
export const waitForClientSideNavigation = async (page: Page, opts: WaitForClientSideNavigationOpts): Promise<void> => {
await Promise.all([
waitFor(() => {
const conditions: boolean[] = []
if (opts.from) {
conditions.push(page.url() !== opts.from)
}
if (opts.to) {
conditions.push(page.url() === opts.to)
}
const unmetConditions = conditions.filter((condition) => !condition)
return Promise.resolve(unmetConditions.length === 0)
}),
page.waitForLoadState("networkidle"),
])
}

View File

@ -4,57 +4,89 @@
package site
import (
"bytes"
"embed"
"fmt"
"io"
"io/fs"
"net/http"
"path"
"path/filepath"
"strings"
"text/template" // html/template escapes some nonces
"time"
"github.com/justinas/nosurf"
"github.com/unrolled/secure"
"cdr.dev/slog"
"github.com/coder/coder/site/nextrouter"
"golang.org/x/xerrors"
)
// The `embed` package ignores recursively including directories
// that prefix with `_`. Wildcarding nested is janky, but seems to
// work quite well for edge-cases.
//go:embed out/_next/*/*/*/*
//go:embed out/_next/*/*/*
//go:embed out/bin/*
//go:embed out
//go:embed out/bin/*
var site embed.FS
// Handler returns an HTTP handler for serving the static site.
func Handler(logger slog.Logger) http.Handler {
filesystem, err := fs.Sub(site, "out")
func DefaultHandler() http.Handler {
// the out directory is where webpack builds are created. It is in the same
// directory as this file (package site).
siteFS, err := fs.Sub(site, "out")
if err != nil {
// This can't happen... Go would throw a compilation error.
panic(err)
}
// Render CSP and CSRF in the served pages
templateFunc := func(r *http.Request) interface{} {
return htmlState{
// Nonce is the CSP nonce for the given request (if there is one present)
CSP: cspState{Nonce: secure.CSPNonce(r.Context())},
// Token is the CSRF token for the given request
CSRF: csrfState{Token: nosurf.Token(r)},
}
return Handler(siteFS)
}
// Handler returns an HTTP handler for serving the static site.
func Handler(fileSystem fs.FS) http.Handler {
// html files are handled by a text/template. Non-html files
// are served by the default file server.
//
// REMARK: text/template is needed to inject values on each request like
// CSRF.
files, err := htmlFiles(fileSystem)
if err != nil {
panic(xerrors.Errorf("Failed to return handler for static files. Html files failed to load: %w", err))
}
nextRouterHandler, err := nextrouter.Handler(filesystem, &nextrouter.Options{
Logger: logger,
TemplateDataFunc: templateFunc,
return secureHeaders(&handler{
fs: fileSystem,
htmlFiles: files,
h: http.FileServer(http.FS(fileSystem)), // All other non-html static files
})
if err != nil {
// There was an error setting up our file system handler.
// This likely means a problem with our embedded file system.
panic(err)
}
type handler struct {
fs fs.FS
// htmlFiles is the text/template for all *.html files.
// This is needed to support Content Security Policy headers.
// Due to material UI, we are forced to use a nonce to allow inline
// scripts, and that nonce is passed through a template.
// We only do this for html files to reduce the amount of in memory caching
// of duplicate files as `fs`.
htmlFiles *htmlTemplates
h http.Handler
}
// filePath returns the filepath of the requested file.
func filePath(p string) string {
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
return secureHeaders(nextRouterHandler)
return strings.TrimPrefix(path.Clean(p), "/")
}
func (h *handler) exists(filePath string) bool {
f, err := h.fs.Open(filePath)
if err == nil {
_ = f.Close()
}
return err == nil
}
type htmlState struct {
@ -70,30 +102,141 @@ type csrfState struct {
Token string
}
// cspDirectives is a map of all csp fetch directives to their values.
func ShouldCacheFile(reqFile string) bool {
// Images, favicons and uniquely content hashed bundle assets should be
// cached. By default, we cache everything in the site/out directory except
// for deny-listed items enumerated here. The reason for this approach is
// that cache invalidation techniques should be used by default for all
// webpack-processed assets. The scenarios where we don't use cache
// invalidation techniques are one-offs or things that should have
// invalidation in the future.
denyListedSuffixes := []string{
// ALL *.html files
".html",
// ALL *worker.js files (including service-worker.js)
//
// REMARK(Grey): I'm unsure if there's a desired setting in Workbox for
// content hashing these, or if doing so is a risk for
// users that have a PWA installed.
"worker.js",
}
for _, suffix := range denyListedSuffixes {
if strings.HasSuffix(reqFile, suffix) {
return false
}
}
return true
}
func (h *handler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
// reqFile is the static file requested
reqFile := filePath(req.URL.Path)
state := htmlState{
// Token is the CSRF token for the given request
CSRF: csrfState{Token: nosurf.Token(req)},
}
// First check if it's a file we have in our templates
if h.serveHTML(resp, req, reqFile, state) {
return
}
// If the original file path exists we serve it.
if h.exists(reqFile) {
if ShouldCacheFile(reqFile) {
resp.Header().Add("Cache-Control", "public, max-age=31536000, immutable")
}
h.h.ServeHTTP(resp, req)
return
}
// Serve the file assuming it's an html file
// This matches paths like `/app/terminal.html`
req.URL.Path = strings.TrimSuffix(req.URL.Path, "/")
req.URL.Path += ".html"
reqFile = filePath(req.URL.Path)
// All html files should be served by the htmlFile templates
if h.serveHTML(resp, req, reqFile, state) {
return
}
// If we don't have the file... we should redirect to `/`
// for our single-page-app.
req.URL.Path = "/"
if h.serveHTML(resp, req, "", state) {
return
}
// This will send a correct 404
h.h.ServeHTTP(resp, req)
}
func (h *handler) serveHTML(resp http.ResponseWriter, request *http.Request, reqPath string, state htmlState) bool {
if data, err := h.htmlFiles.renderWithState(reqPath, state); err == nil {
if reqPath == "" {
// Pass "index.html" to the ServeContent so the ServeContent sets the right content headers.
reqPath = "index.html"
}
http.ServeContent(resp, request, reqPath, time.Time{}, bytes.NewReader(data))
return true
}
return false
}
type htmlTemplates struct {
tpls *template.Template
}
// renderWithState will render the file using the given nonce if the file exists
// as a template. If it does not, it will return an error.
func (t *htmlTemplates) renderWithState(filePath string, state htmlState) ([]byte, error) {
var buf bytes.Buffer
if filePath == "" {
filePath = "index.html"
}
err := t.tpls.ExecuteTemplate(&buf, filePath, state)
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// CSPDirectives is a map of all csp fetch directives to their values.
// Each directive is a set of values that is joined by a space (' ').
// All directives are semi-colon separated as a single string for the csp header.
type cspDirectives map[cspFetchDirective][]string
type CSPDirectives map[CSPFetchDirective][]string
// cspFetchDirective is the list of all constant fetch directives that
func (s CSPDirectives) Append(d CSPFetchDirective, values ...string) {
if _, ok := s[d]; !ok {
s[d] = make([]string, 0)
}
s[d] = append(s[d], values...)
}
// CSPFetchDirective is the list of all constant fetch directives that
// can be used/appended to.
type cspFetchDirective string
type CSPFetchDirective string
const (
cspDirectiveDefaultSrc = "default-src"
cspDirectiveConnectSrc = "connect-src"
cspDirectiveChildSrc = "child-src"
cspDirectiveScriptSrc = "script-src"
cspDirectiveFontSrc = "font-src"
cspDirectiveStyleSrc = "style-src"
cspDirectiveObjectSrc = "object-src"
cspDirectiveManifestSrc = "manifest-src"
cspDirectiveFrameSrc = "frame-src"
cspDirectiveImgSrc = "img-src"
cspDirectiveReportURI = "report-uri"
cspDirectiveFormAction = "form-action"
cspDirectiveMediaSrc = "media-src"
cspFrameAncestors = "frame-ancestors"
CSPDirectiveDefaultSrc = "default-src"
CSPDirectiveConnectSrc = "connect-src"
CSPDirectiveChildSrc = "child-src"
CSPDirectiveScriptSrc = "script-src"
CSPDirectiveFontSrc = "font-src"
CSPDirectiveStyleSrc = "style-src"
CSPDirectiveObjectSrc = "object-src"
CSPDirectiveManifestSrc = "manifest-src"
CSPDirectiveFrameSrc = "frame-src"
CSPDirectiveImgSrc = "img-src"
CSPDirectiveReportURI = "report-uri"
CSPDirectiveFormAction = "form-action"
CSPDirectiveMediaSrc = "media-src"
CSPFrameAncestors = "frame-ancestors"
)
// secureHeaders is only needed for statically served files. We do not need this for api endpoints.
@ -104,26 +247,26 @@ func secureHeaders(next http.Handler) http.Handler {
// If we ever want to render something like a PDF, we need to adjust "object-src"
//
// The list of CSP options: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src
cspSrcs := cspDirectives{
cspSrcs := CSPDirectives{
// All omitted fetch csp srcs default to this.
cspDirectiveDefaultSrc: {"'self'"},
cspDirectiveConnectSrc: {"'self' ws: wss:"},
cspDirectiveChildSrc: {"'self'"},
cspDirectiveScriptSrc: {"'self'"},
cspDirectiveFontSrc: {"'self'"},
cspDirectiveStyleSrc: {"'self' 'unsafe-inline'"},
CSPDirectiveDefaultSrc: {"'self'"},
CSPDirectiveConnectSrc: {"'self' ws: wss:"},
CSPDirectiveChildSrc: {"'self'"},
CSPDirectiveScriptSrc: {"'self'"},
CSPDirectiveFontSrc: {"'self'"},
CSPDirectiveStyleSrc: {"'self' 'unsafe-inline'"},
// object-src is needed to support code-server
cspDirectiveObjectSrc: {"'self'"},
CSPDirectiveObjectSrc: {"'self'"},
// blob: for loading the pwa manifest for code-server
cspDirectiveManifestSrc: {"'self' blob:"},
cspDirectiveFrameSrc: {"'self'"},
CSPDirectiveManifestSrc: {"'self' blob:"},
CSPDirectiveFrameSrc: {"'self'"},
// data: for loading base64 encoded icons for generic applications.
cspDirectiveImgSrc: {"'self' https://cdn.coder.com data:"},
cspDirectiveFormAction: {"'self'"},
cspDirectiveMediaSrc: {"'self'"},
CSPDirectiveImgSrc: {"'self' https://cdn.coder.com data:"},
CSPDirectiveFormAction: {"'self'"},
CSPDirectiveMediaSrc: {"'self'"},
// Report all violations back to the server to log
cspDirectiveReportURI: {"/api/private/csp/reports"},
cspFrameAncestors: {"'none'"},
CSPDirectiveReportURI: {"/api/private/csp/reports"},
CSPFrameAncestors: {"'none'"},
// Only scripts can manipulate the dom. This prevents someone from
// naming themselves something like '<svg onload="alert(/cross-site-scripting/)" />'.
@ -171,3 +314,52 @@ func secureHeaders(next http.Handler) http.Handler {
ReferrerPolicy: "no-referrer",
}).Handler(next)
}
// htmlFiles recursively walks the file system passed finding all *.html files.
// The template returned has all html files parsed.
func htmlFiles(files fs.FS) (*htmlTemplates, error) {
// root is the collection of html templates. All templates are named by their pathing.
// So './404.html' is named '404.html'. './subdir/index.html' is 'subdir/index.html'
root := template.New("")
rootPath := "."
err := fs.WalkDir(files, rootPath, func(filePath string, directory fs.DirEntry, err error) error {
if err != nil {
return err
}
if directory.IsDir() {
return nil
}
if filepath.Ext(directory.Name()) != ".html" {
return nil
}
file, err := files.Open(filePath)
if err != nil {
return err
}
data, err := io.ReadAll(file)
if err != nil {
return err
}
tPath := strings.TrimPrefix(filePath, rootPath+string(filepath.Separator))
_, err = root.New(tPath).Parse(string(data))
if err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
return &htmlTemplates{
tpls: root,
}, nil
}

View File

@ -5,10 +5,8 @@ package site
import (
"net/http"
"cdr.dev/slog"
)
func Handler(logger slog.Logger) http.Handler {
func DefaultHandler() http.Handler {
return http.NotFoundHandler()
}

View File

@ -5,28 +5,170 @@ package site_test
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
"time"
"github.com/stretchr/testify/require"
"cdr.dev/slog"
"github.com/coder/coder/site"
)
func TestIndexPageRenders(t *testing.T) {
func TestCaching(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(site.Handler(slog.Logger{}))
// Create a test server
rootFS := fstest.MapFS{
"bundle.js": &fstest.MapFile{},
"image.png": &fstest.MapFile{},
"static/image.png": &fstest.MapFile{},
"favicon.ico": &fstest.MapFile{
Data: []byte("folderFile"),
},
req, err := http.NewRequestWithContext(context.Background(), "GET", srv.URL, nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err, "get index")
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
require.NotEmpty(t, data, "index should have contents")
"service-worker.js": &fstest.MapFile{},
"index.html": &fstest.MapFile{
Data: []byte("folderFile"),
},
"terminal.html": &fstest.MapFile{
Data: []byte("folderFile"),
},
}
srv := httptest.NewServer(site.Handler(rootFS))
defer srv.Close()
// Create a context
ctx, cancelFunc := context.WithTimeout(context.Background(), 1*time.Second)
defer cancelFunc()
testCases := []struct {
path string
isExpectingCache bool
}{
{"/bundle.js", true},
{"/image.png", true},
{"/static/image.png", true},
{"/favicon.ico", true},
{"/", false},
{"/service-worker.js", false},
{"/index.html", false},
{"/double/nested/terminal.html", false},
}
for _, testCase := range testCases {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+testCase.path, nil)
require.NoError(t, err, "create request")
res, err := srv.Client().Do(req)
require.NoError(t, err, "get index")
cache := res.Header.Get("Cache-Control")
if testCase.isExpectingCache {
require.Equalf(t, "public, max-age=31536000, immutable", cache, "expected %w file to have immutable cache", testCase.path)
} else {
require.Equalf(t, "", cache, "expected %w file to not have immutable cache header", testCase.path)
}
require.NoError(t, res.Body.Close(), "closing response")
}
}
func TestServingFiles(t *testing.T) {
t.Parallel()
// Create a test server
rootFS := fstest.MapFS{
"index.html": &fstest.MapFile{
Data: []byte("index-bytes"),
},
"favicon.ico": &fstest.MapFile{
Data: []byte("favicon-bytes"),
},
"dashboard.js": &fstest.MapFile{
Data: []byte("dashboard-js-bytes"),
},
"dashboard.css": &fstest.MapFile{
Data: []byte("dashboard-css-bytes"),
},
}
srv := httptest.NewServer(site.Handler(rootFS))
defer srv.Close()
// Create a context
ctx, cancelFunc := context.WithTimeout(context.Background(), 1*time.Second)
defer cancelFunc()
var testCases = []struct {
path string
expected string
}{
// Index cases
{"/", "index-bytes"},
{"/index.html", "index-bytes"},
{"/nested", "index-bytes"},
{"/nested/", "index-bytes"},
{"/nested/index.html", "index-bytes"},
// These are nested paths that should lead back to index. We don't
// allow nested JS or CSS files.
{"/double/nested", "index-bytes"},
{"/double/nested/", "index-bytes"},
{"/double/nested/index.html", "index-bytes"},
{"/nested/dashboard.js", "index-bytes"},
{"/nested/dashboard.css", "index-bytes"},
{"/double/nested/dashboard.js", "index-bytes"},
{"/double/nested/dashboard.css", "index-bytes"},
// Favicon cases
// The favicon is always root-referenced in index.html:
{"/favicon.ico", "favicon-bytes"},
// JS, CSS cases
{"/dashboard.js", "dashboard-js-bytes"},
{"/dashboard.css", "dashboard-css-bytes"},
}
for _, testCase := range testCases {
path := srv.URL + testCase.path
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err, "get file")
data, _ := io.ReadAll(resp.Body)
require.Equal(t, string(data), testCase.expected, "Verify file: "+testCase.path)
err = resp.Body.Close()
require.NoError(t, err)
}
}
func TestShouldCacheFile(t *testing.T) {
t.Parallel()
var testCases = []struct {
reqFile string
expected bool
}{
{"123456789.js", true},
{"apps/app/code/terminal.css", true},
{"image.png", true},
{"static/image.png", true},
{"static/images/section-a/image.jpeg", true},
{"service-worker.js", false},
{"dashboard.html", false},
{"apps/app/code/terminal.html", false},
}
for _, testCase := range testCases {
got := site.ShouldCacheFile(testCase.reqFile)
require.Equal(t, testCase.expected, got, fmt.Sprintf("Expected ShouldCacheFile(%s) to be %t", testCase.reqFile, testCase.expected))
}
}

View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<head>
<meta charset="utf-8" />
<link rel="mask-icon" href="/favicon.svg" color="#000000" />
<link rel="alternate icon" type="image/png" href="/favicon.png" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="application-name" content="Coder" />
<meta name="theme-color" content="#17172E" />
<meta property="og:type" content="website" />
<meta property="csrf-token" content="{{ .CSRF.Token }}" />
<meta property="csp-nonce" content="{{ .CSP.Nonce }}" />
<link crossorigin="use-credentials" rel="mask-icon" href="/static/favicon.svg" color="#000000" />
<title>Coder</title>
</head>
<body>
<div id="root">
<!-- Anything within #root will be destroyed on React mount -->
</div>
</body>

View File

@ -11,7 +11,6 @@ module.exports = {
preset: "ts-jest",
roots: ["<rootDir>"],
setupFilesAfterEnv: ["<rootDir>/_jest/setupTests.ts"],
transform: {
"^.+\\.tsx?$": "ts-jest",
},
@ -24,15 +23,7 @@ module.exports = {
displayName: "lint",
runner: "jest-runner-eslint",
testMatch: ["<rootDir>/**/*.js", "<rootDir>/**/*.ts", "<rootDir>/**/*.tsx"],
testPathIgnorePatterns: [
"/.next/",
"/out/",
"/_jest/",
"dev.ts",
"jest.config.js",
"jest-runner.*.js",
"next.config.js",
],
testPathIgnorePatterns: ["/out/", "/_jest/", "jest.config.js", "jest-runner.*.js"],
},
],
collectCoverageFrom: [
@ -41,15 +32,12 @@ module.exports = {
"<rootDir>/**/*.tsx",
"!<rootDir>/**/*.stories.tsx",
"!<rootDir>/_jest/**/*.*",
"!<rootDir>/.next/**/*.*",
"!<rootDir>/api.ts",
"!<rootDir>/coverage/**/*.*",
"!<rootDir>/dev.ts",
"!<rootDir>/e2e/**/*.*",
"!<rootDir>/jest-runner.eslint.config.js",
"!<rootDir>/jest.config.js",
"!<rootDir>/next-env.d.ts",
"!<rootDir>/next.config.js",
"!<rootDir>/webpack.*.ts",
"!<rootDir>/out/**/*.*",
"!<rootDir>/storybook-static/**/*.*",
],

5
site/next-env.d.ts vendored
View File

@ -1,5 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@ -1,25 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-var-requires */
module.exports = {
env: {},
experimental: {
// Allows us to import TS files from outside product/coder/site.
externalDir: true,
},
webpack: (config, { isServer, webpack }) => {
// Inject CODERD_HOST environment variable for clients
if (!isServer) {
config.plugins.push(
new webpack.DefinePlugin({
"process.env.CODERD_HOST": JSON.stringify(process.env.CODERD_HOST),
}),
)
}
return config
},
}

View File

@ -1,260 +0,0 @@
package nextrouter
import (
"bytes"
"context"
"html/template"
"io/fs"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/go-chi/chi/v5"
"cdr.dev/slog"
)
// Options for configuring a nextrouter
type Options struct {
Logger slog.Logger
TemplateDataFunc HTMLTemplateHandler
}
// HTMLTemplateHandler is a function that lets the consumer of `nextrouter`
// inject arbitrary template parameters, based on the request. This is useful
// if the Request object is carrying CSRF tokens, session tokens, etc -
// they can be emitted in the page.
type HTMLTemplateHandler func(*http.Request) interface{}
// Handler returns an HTTP handler for serving a next-based static site
// This handler respects NextJS-based routing rules:
// https://nextjs.org/docs/routing/dynamic-routes
//
// 1) If a file is of the form `[org]`, it's a dynamic route for a single-parameter
// 2) If a file is of the form `[[...any]]`, it's a dynamic route for any parameters
func Handler(fileSystem fs.FS, options *Options) (http.Handler, error) {
if options == nil {
options = &Options{
Logger: slog.Logger{},
TemplateDataFunc: nil,
}
}
router := chi.NewRouter()
// Build up a router that matches NextJS routing rules, for HTML files
err := registerRoutes(router, fileSystem, *options)
if err != nil {
return nil, err
}
// Fallback to static file server for non-HTML files
router.NotFound(FileHandler(fileSystem))
// Finally, if there is a 404.html available, serve that
err = register404(fileSystem, router, *options)
if err != nil {
// An error may be expected if a 404.html is not present
options.Logger.Warn(context.Background(), "Unable to find 404.html", slog.Error(err))
}
return router, nil
}
// FileHandler serves static content, additionally adding immutable
// cache-control headers for Next.js content
func FileHandler(fileSystem fs.FS) func(writer http.ResponseWriter, request *http.Request) {
// Non-HTML files don't have special routing rules, so we can just leverage
// the built-in http.FileServer for it.
fileHandler := http.FileServer(http.FS(fileSystem))
return func(writer http.ResponseWriter, request *http.Request) {
// From the Next.js documentation:
//
// "Caching improves response times and reduces the number
// of requests to external services. Next.js automatically
// adds caching headers to immutable assets served from
// /_next/static including JavaScript, CSS, static images,
// and other media."
//
// See: https://nextjs.org/docs/going-to-production
if strings.HasPrefix(request.URL.Path, "/_next/static/") {
writer.Header().Add("Cache-Control", "public, max-age=31536000, immutable")
}
fileHandler.ServeHTTP(writer, request)
}
}
// registerRoutes recursively traverses the file-system, building routes
// as appropriate for respecting NextJS dynamic rules.
func registerRoutes(rtr chi.Router, fileSystem fs.FS, options Options) error {
files, err := fs.ReadDir(fileSystem, ".")
if err != nil {
return err
}
// Loop through everything in the current directory...
for _, file := range files {
name := file.Name()
// If we're working with a file - just serve it up
if !file.IsDir() {
serveFile(rtr, fileSystem, name, options)
continue
}
// ...otherwise, if it's a directory, create a sub-route by
// recursively calling `buildRouter`
sub, err := fs.Sub(fileSystem, name)
if err != nil {
return err
}
// In the special case where the folder is dynamic,
// like `[org]`, we can convert to a chi-style dynamic route
// (which uses `{` instead of `[`)
routeName := name
if isDynamicRoute(name) {
routeName = "{dynamic}"
}
options.Logger.Debug(context.Background(), "Registering route", slog.F("name", name), slog.F("routeName", routeName))
rtr.Route("/"+routeName, func(r chi.Router) {
err := registerRoutes(r, sub, options)
if err != nil {
options.Logger.Error(context.Background(), "Error registering route", slog.F("name", routeName), slog.Error(err))
}
})
}
return nil
}
// serveFile is responsible for serving up HTML files in our next router
// It handles various special cases, like trailing-slashes or handling routes w/o the .html suffix.
func serveFile(router chi.Router, fileSystem fs.FS, fileName string, options Options) {
// We only handle .html files for now
ext := filepath.Ext(fileName)
if ext != ".html" {
return
}
options.Logger.Debug(context.Background(), "Reading file", slog.F("fileName", fileName))
data, err := fs.ReadFile(fileSystem, fileName)
if err != nil {
options.Logger.Error(context.Background(), "Unable to read file", slog.F("fileName", fileName))
return
}
// Create a template from the data - we can inject custom parameters like CSRF here
tpls, err := template.New(fileName).Parse(string(data))
if err != nil {
options.Logger.Error(context.Background(), "Unable to create template for file", slog.F("fileName", fileName))
return
}
handler := func(writer http.ResponseWriter, request *http.Request) {
var buf bytes.Buffer
// See if there are any template parameters we need to inject!
// Things like CSRF tokens, etc...
//templateData := struct{}{}
var templateData interface{}
templateData = nil
if options.TemplateDataFunc != nil {
templateData = options.TemplateDataFunc(request)
}
options.Logger.Debug(context.Background(), "Applying template parameters", slog.F("fileName", fileName), slog.F("templateData", templateData))
err := tpls.ExecuteTemplate(&buf, fileName, templateData)
if err != nil {
options.Logger.Error(request.Context(), "Error executing template", slog.F("template_parameters", templateData))
http.Error(writer, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
http.ServeContent(writer, request, fileName, time.Time{}, bytes.NewReader(buf.Bytes()))
}
fileNameWithoutExtension := removeFileExtension(fileName)
// Handle the `[[...any]]` catch-all case
if isCatchAllRoute(fileNameWithoutExtension) {
options.Logger.Info(context.Background(), "Registering catch-all route", slog.F("fileName", fileName))
router.NotFound(handler)
return
}
// Handle the `[org]` dynamic route case
if isDynamicRoute(fileNameWithoutExtension) {
options.Logger.Debug(context.Background(), "Registering dynamic route", slog.F("fileName", fileName))
router.Get("/{dynamic}", handler)
return
}
// Handle the basic file cases
// Directly accessing a file, ie `/providers.html`
router.Get("/"+fileName, handler)
// Accessing a file without an extension, ie `/providers`
router.Get("/"+fileNameWithoutExtension, handler)
// Special case: '/' should serve index.html
if fileName == "index.html" {
router.Get("/", handler)
return
}
// Otherwise, handling the trailing slash case -
// for examples, `providers.html` should serve `/providers/`
router.Get("/"+fileNameWithoutExtension+"/", handler)
}
func register404(fileSystem fs.FS, router chi.Router, options Options) error {
// Get the file contents
fileBytes, err := fs.ReadFile(fileSystem, "404.html")
if err != nil {
// An error is expected if the file doesn't exist
return err
}
router.NotFound(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusNotFound)
_, err = writer.Write(fileBytes)
if err != nil {
options.Logger.Error(request.Context(), "Unable to write bytes for 404")
return
}
})
return nil
}
// isDynamicRoute returns true if the file is a NextJS dynamic route, like `[orgs]`
// Returns false if the file is not a dynamic route, or if it is a catch-all route (`[[...any]]`)
// NOTE: The extension should be removed from the file name
func isDynamicRoute(fileWithoutExtension string) bool {
// Assuming ASCII encoding - `len` in go works on bytes
byteLen := len(fileWithoutExtension)
if byteLen < 2 {
return false
}
return fileWithoutExtension[0] == '[' && fileWithoutExtension[1] != '[' && fileWithoutExtension[byteLen-1] == ']'
}
// isCatchAllRoute returns true if the file is a catch-all route, like `[[...any]]`
// Return false otherwise
// NOTE: The extension should be removed from the file name
func isCatchAllRoute(fileWithoutExtension string) bool {
ret := strings.HasPrefix(fileWithoutExtension, "[[.")
return ret
}
// removeFileExtension removes the extension from a file
// For example, removeFileExtension("index.html") would return "index"
func removeFileExtension(fileName string) string {
return strings.TrimSuffix(fileName, filepath.Ext(fileName))
}

View File

@ -1,476 +0,0 @@
package nextrouter_test
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
"time"
"github.com/stretchr/testify/require"
"cdr.dev/slog"
"github.com/coder/coder/site/nextrouter"
)
func TestNextRouter(t *testing.T) {
t.Parallel()
t.Run("Serves file at root", func(t *testing.T) {
t.Parallel()
rootFS := fstest.MapFS{
"test.html": &fstest.MapFile{
Data: []byte("test123"),
},
}
router, err := nextrouter.Handler(rootFS, nil)
require.NoError(t, err)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
res, err := request(server, "/test.html")
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
require.EqualValues(t, "test123", body)
require.Equal(t, http.StatusOK, res.StatusCode)
})
// This is a test case for the issue we hit in V1 w/ NextJS migration
t.Run("Prefer file over folder w/ trailing slash", func(t *testing.T) {
t.Parallel()
rootFS := fstest.MapFS{
"folder/test.html": &fstest.MapFile{},
"folder.html": &fstest.MapFile{
Data: []byte("folderFile"),
},
}
router, err := nextrouter.Handler(rootFS, nil)
require.NoError(t, err)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
res, err := request(server, "/folder/")
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
require.EqualValues(t, "folderFile", body)
require.Equal(t, http.StatusOK, res.StatusCode)
})
t.Run("Serves non-html files at root", func(t *testing.T) {
t.Parallel()
rootFS := fstest.MapFS{
"test.png": &fstest.MapFile{
Data: []byte("png-bytes"),
},
}
router, err := nextrouter.Handler(rootFS, nil)
require.NoError(t, err)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
res, err := request(server, "/test.png")
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
require.Equal(t, "image/png", res.Header.Get("Content-Type"))
require.EqualValues(t, "png-bytes", body)
require.Equal(t, http.StatusOK, res.StatusCode)
})
t.Run("Serves html file without extension", func(t *testing.T) {
t.Parallel()
rootFS := fstest.MapFS{
"test.html": &fstest.MapFile{
Data: []byte("test-no-extension"),
},
}
router, err := nextrouter.Handler(rootFS, nil)
require.NoError(t, err)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
res, err := request(server, "/test")
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
require.EqualValues(t, "test-no-extension", body)
require.Equal(t, http.StatusOK, res.StatusCode)
})
t.Run("Defaults to index.html at root", func(t *testing.T) {
t.Parallel()
rootFS := fstest.MapFS{
"index.html": &fstest.MapFile{
Data: []byte("test-root-index"),
},
}
router, err := nextrouter.Handler(rootFS, nil)
require.NoError(t, err)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
res, err := request(server, "/")
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
require.Equal(t, "text/html; charset=utf-8", res.Header.Get("Content-Type"))
require.EqualValues(t, "test-root-index", body)
require.Equal(t, http.StatusOK, res.StatusCode)
})
t.Run("Serves nested file", func(t *testing.T) {
t.Parallel()
rootFS := fstest.MapFS{
"test/a/b/c.html": &fstest.MapFile{
Data: []byte("test123"),
},
}
router, err := nextrouter.Handler(rootFS, nil)
require.NoError(t, err)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
res, err := request(server, "/test/a/b/c.html")
require.NoError(t, err)
require.NoError(t, res.Body.Close())
res, err = request(server, "/test/a/b/c.html")
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
require.EqualValues(t, "test123", body)
require.Equal(t, http.StatusOK, res.StatusCode)
})
t.Run("Uses index.html in nested path", func(t *testing.T) {
t.Parallel()
rootFS := fstest.MapFS{
"test/a/b/c/index.html": &fstest.MapFile{
Data: []byte("test-abc-index"),
},
}
router, err := nextrouter.Handler(rootFS, nil)
require.NoError(t, err)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
res, err := request(server, "/test/a/b/c")
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
require.EqualValues(t, "test-abc-index", body)
require.Equal(t, http.StatusOK, res.StatusCode)
})
t.Run("404 if file at root is not found", func(t *testing.T) {
t.Parallel()
rootFS := fstest.MapFS{
"test.html": &fstest.MapFile{
Data: []byte("test123"),
},
}
router, err := nextrouter.Handler(rootFS, nil)
require.NoError(t, err)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
res, err := request(server, "/test-non-existent.html")
require.NoError(t, err)
require.NoError(t, res.Body.Close())
require.Equal(t, http.StatusNotFound, res.StatusCode)
})
t.Run("404 if file at root is not found", func(t *testing.T) {
t.Parallel()
rootFS := fstest.MapFS{
"test.html": &fstest.MapFile{
Data: []byte("test123"),
},
}
router, err := nextrouter.Handler(rootFS, nil)
require.NoError(t, err)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
res, err := request(server, "/test-non-existent.html")
require.NoError(t, err)
require.NoError(t, res.Body.Close())
require.Equal(t, http.StatusNotFound, res.StatusCode)
})
t.Run("Serve custom 404.html if available", func(t *testing.T) {
t.Parallel()
rootFS := fstest.MapFS{
"404.html": &fstest.MapFile{
Data: []byte("404 custom content"),
},
}
router, err := nextrouter.Handler(rootFS, nil)
require.NoError(t, err)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
res, err := request(server, "/test-non-existent.html")
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
require.Equal(t, http.StatusNotFound, res.StatusCode)
require.EqualValues(t, "404 custom content", body)
})
t.Run("Serves dynamic-routed file", func(t *testing.T) {
t.Parallel()
rootFS := fstest.MapFS{
"folder/[orgs].html": &fstest.MapFile{
Data: []byte("test-dynamic-path"),
},
}
router, err := nextrouter.Handler(rootFS, nil)
require.NoError(t, err)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
res, err := request(server, "/folder/org-1")
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
require.Equal(t, http.StatusOK, res.StatusCode)
require.EqualValues(t, "test-dynamic-path", body)
})
t.Run("Handles dynamic-routed folders", func(t *testing.T) {
t.Parallel()
rootFS := fstest.MapFS{
"folder/[org]/[project]/create.html": &fstest.MapFile{
Data: []byte("test-create"),
},
}
router, err := nextrouter.Handler(rootFS, nil)
require.NoError(t, err)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
res, err := request(server, "/folder/org-1/project-1/create")
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
require.Equal(t, http.StatusOK, res.StatusCode)
require.EqualValues(t, "test-create", body)
})
t.Run("Handles catch-all routes", func(t *testing.T) {
t.Parallel()
rootFS := fstest.MapFS{
"folder/[[...any]].html": &fstest.MapFile{
Data: []byte("test-catch-all"),
},
}
router, err := nextrouter.Handler(rootFS, nil)
require.NoError(t, err)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
res, err := request(server, "/folder/org-1/project-1/random")
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
require.Equal(t, http.StatusOK, res.StatusCode)
require.EqualValues(t, "test-catch-all", body)
})
t.Run("Static routes should be preferred to dynamic routes", func(t *testing.T) {
t.Parallel()
rootFS := fstest.MapFS{
"folder/[orgs].html": &fstest.MapFile{
Data: []byte("test-dynamic-path"),
},
"folder/create.html": &fstest.MapFile{
Data: []byte("test-create"),
},
}
router, err := nextrouter.Handler(rootFS, nil)
require.NoError(t, err)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
res, err := request(server, "/folder/create")
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
require.Equal(t, http.StatusOK, res.StatusCode)
require.EqualValues(t, "test-create", body)
})
t.Run("Caching headers for _next resources", func(t *testing.T) {
t.Parallel()
rootFS := fstest.MapFS{
"index.html": &fstest.MapFile{
Data: []byte("test-root"),
},
"_next/static/test.js": &fstest.MapFile{
Data: []byte("test.js cached forever"),
},
"_next/static/chunks/app/test.css": &fstest.MapFile{
Data: []byte("test.css cached forever"),
},
}
router, err := nextrouter.Handler(rootFS, nil)
require.NoError(t, err)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
res, err := request(server, "/index.html")
require.NoError(t, err)
require.NoError(t, res.Body.Close())
require.Equal(t, http.StatusOK, res.StatusCode)
require.Empty(t, res.Header.Get("Cache-Control"))
res, err = request(server, "/_next/static/test.js")
require.NoError(t, err)
require.NoError(t, res.Body.Close())
require.Equal(t, http.StatusOK, res.StatusCode)
require.Equal(t, "public, max-age=31536000, immutable", res.Header.Get("Cache-Control"))
})
t.Run("Injects template parameters", func(t *testing.T) {
t.Parallel()
rootFS := fstest.MapFS{
"test.html": &fstest.MapFile{
Data: []byte("{{ .CSRF.Token }}"),
},
}
type csrfState struct {
Token string
}
type template struct {
CSRF csrfState
}
// Add custom template function
templateFunc := func(request *http.Request) interface{} {
return template{
CSRF: csrfState{
Token: "hello-csrf",
},
}
}
router, err := nextrouter.Handler(rootFS, &nextrouter.Options{
Logger: slog.Logger{},
TemplateDataFunc: templateFunc,
})
require.NoError(t, err)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
res, err := request(server, "/test.html")
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
require.Equal(t, http.StatusOK, res.StatusCode)
require.EqualValues(t, "hello-csrf", body)
})
}
func request(server *httptest.Server, path string) (*http.Response, error) {
ctx, cancelFunc := context.WithTimeout(context.Background(), 1*time.Second)
defer cancelFunc()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+path, nil)
if err != nil {
return nil, err
}
res, err := server.Client().Do(req)
if err != nil {
return nil, err
}
return res, err
}

View File

@ -4,10 +4,9 @@
"repository": "https://github.com/coder/coder",
"private": true,
"scripts": {
"build": "NODE_ENV=production next build",
"build:dev": "next build",
"dev": "ts-node dev.ts",
"export": "next export",
"build": "NODE_ENV=production webpack build --config=webpack.prod.ts",
"build:analyze": "NODE_ENV=production webpack --profile --progress --json --config=webpack.prod.ts > out/stats.json && webpack-bundle-analyzer out/stats.json out",
"dev": "webpack-dev-server --config=webpack.dev.ts",
"format:check": "prettier --check '**/*.{css,html,js,json,jsx,md,ts,tsx,yaml,yml}'",
"format:write": "prettier --write '**/*.{css,html,js,json,jsx,md,ts,tsx,yaml,yml}' && sql-formatter -l postgresql ./database/query.sql -o ./database/query.sql",
"lint": "jest --selectProjects lint",
@ -25,6 +24,7 @@
"@material-ui/icons": "4.5.1",
"@material-ui/lab": "4.0.0-alpha.42",
"@playwright/test": "1.19.2",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.4",
"@react-theming/storybook-addon": "1.1.5",
"@storybook/addon-actions": "6.4.19",
"@storybook/addon-essentials": "6.4.19",
@ -39,6 +39,7 @@
"@types/superagent": "4.1.15",
"@typescript-eslint/eslint-plugin": "4.33.0",
"@typescript-eslint/parser": "4.33.0",
"copy-webpack-plugin": "10.2.4",
"eslint": "7.32.0",
"eslint-config-prettier": "8.5.0",
"eslint-import-resolver-alias": "1.1.2",
@ -52,24 +53,29 @@
"eslint-plugin-react-hooks": "4.3.0",
"express": "4.17.3",
"formik": "2.2.9",
"history": "5.3.0",
"html-webpack-plugin": "5.5.0",
"http-proxy-middleware": "2.0.3",
"jest": "27.5.1",
"jest-junit": "13.0.0",
"jest-runner-eslint": "1.0.0",
"next": "12.1.0",
"next-router-mock": "^0.6.5",
"prettier": "2.5.1",
"react": "17.0.2",
"react-dom": "17.0.2",
"sql-formatter": "^4.0.2",
"react-hot-loader": "4.13.0",
"react-router-dom": "6.2.1",
"sql-formatter": "4.0.2",
"swr": "1.2.2",
"ts-jest": "27.1.3",
"ts-loader": "9.2.8",
"ts-node": "10.7.0",
"typescript": "4.6.2",
"webpack": "5.65.0",
"webpack-bundle-analyzer": "4.5.0",
"webpack-cli": "4.9.1",
"webpack-dev-server": "4.7.4",
"yup": "0.32.11"
},
"dependencies": {},
"browserslist": [
"chrome 66",
"firefox 63",

33
site/pages/404.tsx Normal file
View File

@ -0,0 +1,33 @@
import { makeStyles } from "@material-ui/core/styles"
import React from "react"
import Typography from "@material-ui/core/Typography"
export const NotFoundPage: React.FC = () => {
const styles = useStyles()
return (
<div className={styles.root}>
<div className={styles.headingContainer}>
<Typography variant="h4">404</Typography>
</div>
<Typography variant="body2">This page could not be found.</Typography>
</div>
)
}
const useStyles = makeStyles((theme) => ({
root: {
width: "100vw",
height: "100vh",
display: "flex",
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
},
headingContainer: {
margin: theme.spacing(1),
padding: theme.spacing(1),
borderRight: theme.palette.divider,
},
}))

View File

@ -1,48 +0,0 @@
import React from "react"
import CssBaseline from "@material-ui/core/CssBaseline"
import ThemeProvider from "@material-ui/styles/ThemeProvider"
import { SWRConfig } from "swr"
import { AppProps } from "next/app"
import { UserProvider } from "../contexts/UserContext"
import { light } from "../theme"
/**
* ClientRender is a component that only allows its children to be rendered
* client-side. This check is performed by querying the existence of the window
* global.
*/
const ClientRender: React.FC = ({ children }) => (
<div suppressHydrationWarning>{typeof window === "undefined" ? null : children}</div>
)
/**
* <App /> is the root rendering logic of the application - setting up our router
* and any contexts / global state management.
*/
const MyApp: React.FC<AppProps> = ({ Component, pageProps }) => {
return (
<ClientRender>
<SWRConfig
value={{
fetcher: async (url: string) => {
const res = await fetch(url)
if (!res.ok) {
const err = new Error((await res.json()).error?.message || res.statusText)
throw err
}
return res.json()
},
}}
>
<UserProvider>
<ThemeProvider theme={light}>
<CssBaseline />
<Component {...pageProps} />
</ThemeProvider>
</UserProvider>
</SWRConfig>
</ClientRender>
)
}
export default MyApp

View File

@ -1,33 +0,0 @@
import Document, { DocumentContext, Head, Html, Main, NextScript } from "next/document"
import React from "react"
class MyDocument extends Document {
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
static async getInitialProps(ctx: DocumentContext) {
const initialProps = await Document.getInitialProps(ctx)
return { ...initialProps }
}
render(): JSX.Element {
return (
<Html>
<Head>
<meta charSet="utf-8" />
<meta name="theme-color" content="#17172E" />
<meta name="application-name" content="Coder" />
<meta property="og:type" content="website" />
<meta property="csp-nonce" content="{{ .CSP.Nonce }}" />
<link crossOrigin="use-credentials" rel="mask-icon" href="/static/favicon.svg" color="#000000" />
<link rel="alternate icon" type="image/png" href="/static/favicon.png" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
}
export default MyDocument

View File

@ -6,7 +6,7 @@ import { CliAuthToken } from "../components/SignIn"
import { FullScreenLoader } from "../components/Loader/FullScreenLoader"
import { useUser } from "../contexts/UserContext"
const CliAuthenticationPage: React.FC = () => {
export const CliAuthenticationPage: React.FC = () => {
const { me } = useUser(true)
const styles = useStyles()
@ -40,5 +40,3 @@ const useStyles = makeStyles(() => ({
alignItems: "center",
},
}))
export default CliAuthenticationPage

View File

@ -1,18 +1,16 @@
import React from "react"
import { Redirect } from "../components"
import { Navigate } from "react-router-dom"
import { FullScreenLoader } from "../components/Loader/FullScreenLoader"
import { useUser } from "../contexts/UserContext"
const IndexPage: React.FC = () => {
export const IndexPage: React.FC = () => {
const { me } = useUser(/* redirectOnError */ true)
if (me) {
// Once the user is logged in, just redirect them to /projects as the landing page
return <Redirect to="/projects" />
return <Navigate to="/projects" replace />
}
return <FullScreenLoader />
}
export default IndexPage

View File

@ -26,5 +26,3 @@ export const SignInPage: React.FC = () => {
</div>
)
}
export default SignInPage

View File

@ -1,6 +1,6 @@
import React, { useCallback } from "react"
import { makeStyles } from "@material-ui/core/styles"
import { useRouter } from "next/router"
import { useNavigate, useParams } from "react-router-dom"
import useSWR from "swr"
import * as API from "../../../../api"
@ -10,11 +10,11 @@ import { FullScreenLoader } from "../../../../components/Loader/FullScreenLoader
import { CreateWorkspaceForm } from "../../../../forms/CreateWorkspaceForm"
import { unsafeSWRArgument } from "../../../../util"
const CreateWorkspacePage: React.FC = () => {
const { push, query } = useRouter()
export const CreateWorkspacePage: React.FC = () => {
const { organization: organizationName, project: projectName } = useParams()
const navigate = useNavigate()
const styles = useStyles()
const { me } = useUser(/* redirectOnError */ true)
const { organization: organizationName, project: projectName } = query
const { data: organizationInfo, error: organizationError } = useSWR<API.Organization, Error>(
() => `/api/v2/users/me/organizations/${organizationName}`,
@ -25,12 +25,12 @@ const CreateWorkspacePage: React.FC = () => {
})
const onCancel = useCallback(async () => {
await push(`/projects/${organizationName}/${projectName}`)
}, [push, organizationName, projectName])
navigate(`/projects/${organizationName}/${projectName}`)
}, [navigate, organizationName, projectName])
const onSubmit = async (req: API.CreateWorkspaceRequest) => {
const workspace = await API.Workspace.create(req)
await push(`/workspaces/${workspace.id}`)
navigate(`/workspaces/${workspace.id}`)
return workspace
}
@ -62,5 +62,3 @@ const useStyles = makeStyles((theme) => ({
backgroundColor: theme.palette.background.paper,
},
}))
export default CreateWorkspacePage

View File

@ -1,8 +1,7 @@
import React from "react"
import { makeStyles } from "@material-ui/core/styles"
import Paper from "@material-ui/core/Paper"
import Link from "next/link"
import { useRouter } from "next/router"
import { Link, useNavigate, useParams } from "react-router-dom"
import useSWR from "swr"
import { Organization, Project, Workspace } from "../../../../api"
@ -17,12 +16,11 @@ import { firstOrItem } from "../../../../util/array"
import { EmptyState } from "../../../../components/EmptyState"
import { unsafeSWRArgument } from "../../../../util"
const ProjectPage: React.FC = () => {
export const ProjectPage: React.FC = () => {
const styles = useStyles()
const { me, signOut } = useUser(true)
const router = useRouter()
const { project: projectName, organization: organizationName } = router.query
const navigate = useNavigate()
const { project: projectName, organization: organizationName } = useParams()
const { data: organizationInfo, error: organizationError } = useSWR<Organization, Error>(
() => `/api/v2/users/me/organizations/${organizationName}`,
@ -54,7 +52,7 @@ const ProjectPage: React.FC = () => {
}
const createWorkspace = () => {
void router.push(`/projects/${organizationName}/${projectName}/create`)
navigate(`/projects/${organizationName}/${projectName}/create`)
}
const emptyState = (
@ -73,7 +71,7 @@ const ProjectPage: React.FC = () => {
key: "name",
name: "Name",
renderer: (nameField: string, workspace: Workspace) => {
return <Link href={`/workspaces/${workspace.id}`}>{nameField}</Link>
return <Link to={`/workspaces/${workspace.id}`}>{nameField}</Link>
},
},
]
@ -125,5 +123,3 @@ const useStyles = makeStyles((theme) => ({
width: "100%",
},
}))
export default ProjectPage

View File

@ -1,7 +1,7 @@
import React from "react"
import { makeStyles } from "@material-ui/core/styles"
import Paper from "@material-ui/core/Paper"
import Link from "next/link"
import { Link } from "react-router-dom"
import { EmptyState } from "../../components"
import { ErrorSummary } from "../../components/ErrorSummary"
import { Navbar } from "../../components/Navbar"
@ -15,7 +15,7 @@ import { Organization, Project } from "./../../api"
import useSWR from "swr"
import { CodeExample } from "../../components/CodeExample/CodeExample"
const ProjectsPage: React.FC = () => {
export const ProjectsPage: React.FC = () => {
const styles = useStyles()
const { me, signOut } = useUser(true)
const { data: orgs, error: orgsError } = useSWR<Organization[], Error>("/api/v2/users/me/organizations")
@ -49,7 +49,7 @@ const ProjectsPage: React.FC = () => {
key: "name",
name: "Name",
renderer: (nameField: string, data: Project) => {
return <Link href={`/projects/${orgDictionary[data.organization_id]}/${nameField}`}>{nameField}</Link>
return <Link to={`/projects/${orgDictionary[data.organization_id]}/${nameField}`}>{nameField}</Link>
},
},
]
@ -93,5 +93,3 @@ const useStyles = makeStyles((theme) => ({
marginBottom: theme.spacing(1),
},
}))
export default ProjectsPage

View File

@ -1,7 +1,7 @@
import React from "react"
import useSWR from "swr"
import { makeStyles } from "@material-ui/core/styles"
import { useRouter } from "next/router"
import { useParams } from "react-router-dom"
import { Navbar } from "../../components/Navbar"
import { Footer } from "../../components/Page"
import { useUser } from "../../contexts/UserContext"
@ -12,13 +12,11 @@ import { Workspace } from "../../components/Workspace"
import { unsafeSWRArgument } from "../../util"
import * as API from "../../api"
const WorkspacesPage: React.FC = () => {
export const WorkspacePage: React.FC = () => {
const styles = useStyles()
const router = useRouter()
const { workspace: workspaceQueryParam } = useParams()
const { me, signOut } = useUser(true)
const { workspace: workspaceQueryParam } = router.query
const { data: workspace, error: workspaceError } = useSWR<API.Workspace, Error>(() => {
const workspaceParam = firstOrItem(workspaceQueryParam, null)
@ -74,5 +72,3 @@ const useStyles = makeStyles(() => ({
width: "100%",
},
}))
export default WorkspacesPage

View File

@ -3,9 +3,17 @@ import React from "react"
import ThemeProvider from "@material-ui/styles/ThemeProvider"
import { dark } from "../theme"
import { createMemoryHistory } from "history"
import { unstable_HistoryRouter as HistoryRouter } from "react-router-dom"
export const history = createMemoryHistory()
export const WrapperComponent: React.FC = ({ children }) => {
return <ThemeProvider theme={dark}>{children}</ThemeProvider>
return (
<HistoryRouter history={history}>
<ThemeProvider theme={dark}>{children}</ThemeProvider>
</HistoryRouter>
)
}
export const render = (component: React.ReactElement): RenderResult => {

View File

@ -4,7 +4,7 @@
"noImplicitAny": true,
"module": "commonjs",
"target": "es5",
"jsx": "preserve",
"jsx": "react",
"allowJs": true,
"downlevelIteration": true,
"esModuleInterop": true,
@ -15,10 +15,9 @@
"strict": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,
"moduleResolution": "node"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", "_jest"]
"exclude": ["node_modules", "_jest", "**/*.test.tsx"]
}

View File

@ -8,5 +8,6 @@
"strict": true,
"strictNullChecks": true,
"esModuleInterop": true
}
},
"exclude": ["node_modules", "_jest"]
}

58
site/webpack.common.ts Normal file
View File

@ -0,0 +1,58 @@
/**
* @fileoverview This file contains a set of webpack configurations that should
* be shared between development and production.
*/
import * as path from "path"
import { Configuration } from "webpack"
import HtmlWebpackPlugin from "html-webpack-plugin"
const templatePath = path.join(__dirname, "html_templates")
const plugins = [
// The HTML webpack plugin tells webpack to use our `index.html` and inject
// the bundle script, which might have special naming.
new HtmlWebpackPlugin({
template: path.join(templatePath, "index.html"),
publicPath: "/",
}),
]
export const commonWebpackConfig: Configuration = {
// entry defines each "page" or "chunk". Currently, for v2, we only have one bundle -
// a bundle that is shared across all of the UI. However, we may need to eventually split
// like in v1, where there is a separate entry piont for dashboard & terminal.
entry: path.join(__dirname, "Main.tsx"),
// modules specify how different modules are loaded
// See: https://webpack.js.org/concepts/modules/
module: {
rules: [
{
test: /\.tsx?$/,
use: ["ts-loader"],
exclude: [/node_modules/],
},
],
},
resolve: {
// Let webpack know to consider ts/tsx files for bundling
// See: https://webpack.js.org/guides/typescript/
extensions: [".tsx", ".ts", ".js"],
},
// output defines the name and location of the final bundle
output: {
// The chunk name along with a hash of its content will be used for the
// generated bundle name.
//
// REMARK: It's important to use [contenthash] here to invalidate caches.
filename: "bundle.[contenthash].js",
path: path.resolve(__dirname, "out"),
},
plugins: plugins,
target: "web",
}

91
site/webpack.dev.ts Normal file
View File

@ -0,0 +1,91 @@
/**
* @fileoverview This file contains a development configuration for webpack
* meant for webpack-dev-server.
*/
import ReactRefreshWebpackPlugin from "@pmmmwh/react-refresh-webpack-plugin"
import { Configuration } from "webpack"
import "webpack-dev-server"
import { commonWebpackConfig } from "./webpack.common"
const commonPlugins = commonWebpackConfig.plugins || []
const config: Configuration = {
...commonWebpackConfig,
// devtool controls how source maps are generated. In development, we want
// more details (less optimized) for more readability and an easier time
// debugging
devtool: "eval-source-map",
// devServer is the configuration for webpack-dev-server.
//
// REMARK: needs webpack-dev-server import at top of file for typings
devServer: {
// allowedHosts are services that can access the running server.
// Setting allowedHosts sets up the development server to spend specific headers to allow cross-origin requests.
// In v1, we use CODERD_HOST for the allowed host and origin in order to mitigate security risks.
// We don't have an equivalent in v2 - but we can allow localhost and cdr.dev,
// so that the site is accessible through dev urls.
// We don't want to use 'all' or '*' and risk a security hole in our dev environments.
allowedHosts: ["localhost", ".cdr.dev"],
// client configures options that are observed in the browser/web-client.
client: {
// automatically sets the browser console to verbose when using HMR
logging: "verbose",
// errors will display as a full-screen overlay
overlay: true,
// build % progress will display in the browser
progress: true,
// webpack-dev-server uses a webSocket to communicate with the browser
// for HMR. By setting this to auto://0.0.0.0/ws, we allow the browser
// to set the protocal, hostname and port automatically for us.
webSocketURL: "auto://0.0.0.0:0/ws",
},
devMiddleware: {
publicPath: "/",
},
headers: {
"Access-Control-Allow-Origin": "*",
},
// historyApiFallback is required when using history (react-router) for
// properly serving index.html on 404s.
historyApiFallback: true,
hot: true,
port: 8080,
proxy: {
"/api": "http://localhost:3000",
},
static: ["./static"],
},
// Development mode - see:
// https://webpack.js.org/configuration/mode/#mode-development
mode: "development",
output: {
...commonWebpackConfig.output,
// The chunk name will be used as-is for the bundle output
// This is simpler than production, to improve performance
// (no need to calculate hashes in development)
filename: "[name].js",
},
plugins: [
...commonPlugins,
// The ReactRefreshWebpackPlugin enables hot-module reloading:
// https://github.com/pmmmwh/react-refresh-webpack-plugin
new ReactRefreshWebpackPlugin({
overlay: true,
}),
],
}
export default config

36
site/webpack.prod.ts Normal file
View File

@ -0,0 +1,36 @@
/**
* @fileoverview This file contains a production configuration for webpack
* meant for producing optimized builds.
*/
import CopyWebpackPlugin from "copy-webpack-plugin"
import { Configuration } from "webpack"
import { commonWebpackConfig } from "./webpack.common"
const commonPlugins = commonWebpackConfig.plugins || []
export const config: Configuration = {
...commonWebpackConfig,
mode: "production",
// Don't produce sourcemaps in production, to minmize bundle size
devtool: false,
output: {
...commonWebpackConfig.output,
// regenerate the entire out/ directory when producing production builds
clean: true,
},
plugins: [
...commonPlugins,
// For production builds, we also need to copy all the static
// files to the 'out' folder.
new CopyWebpackPlugin({
patterns: [{ from: "static", to: "." }],
}),
],
}
export default config

File diff suppressed because it is too large Load Diff