mirror of https://github.com/coder/coder.git
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:
parent
bf1f858c15
commit
ec077c6191
|
@ -352,6 +352,8 @@ jobs:
|
|||
working-directory: site
|
||||
|
||||
- run: yarn playwright:test
|
||||
env:
|
||||
DEBUG: pw:api
|
||||
working-directory: site
|
||||
|
||||
- name: Upload DataDog Trace
|
||||
|
|
1
Makefile
1
Makefile
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
|
@ -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 {}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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" })
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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
|
||||
}
|
|
@ -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"))
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
export * from "./Button"
|
||||
export * from "./EmptyState"
|
||||
export * from "./Page"
|
||||
export * from "./Redirect"
|
||||
|
|
|
@ -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("")
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
42
site/dev.ts
42
site/dev.ts
|
@ -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)
|
||||
})
|
|
@ -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")
|
||||
})
|
||||
|
|
|
@ -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"),
|
||||
])
|
||||
}
|
310
site/embed.go
310
site/embed.go
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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/**/*.*",
|
||||
],
|
||||
|
|
|
@ -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.
|
|
@ -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
|
||||
},
|
||||
}
|
|
@ -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))
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}))
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -26,5 +26,3 @@ export const SignInPage: React.FC = () => {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SignInPage
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -8,5 +8,6 @@
|
|||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"esModuleInterop": true
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "_jest"]
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
}
|
|
@ -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
|
|
@ -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
|
1158
site/yarn.lock
1158
site/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue