From 8cc11d168853254b158dd3f783385ea11527857e Mon Sep 17 00:00:00 2001 From: Asher Date: Tue, 4 Feb 2020 18:16:45 -0600 Subject: [PATCH] Improve routing --- package.json | 4 +- scripts/build.ts | 18 +++++ src/browser/app.css | 5 ++ src/browser/app.tsx | 41 +++++----- src/browser/components/animate.tsx | 4 + src/browser/components/error.tsx | 3 +- src/browser/components/list.css | 5 +- src/browser/components/list.tsx | 42 ++++++++-- src/browser/components/modal.css | 25 +++--- src/browser/components/modal.tsx | 98 +++++++++++----------- src/browser/index.tsx | 10 +-- src/browser/pages/browse.tsx | 5 +- src/browser/pages/home.tsx | 18 ++--- src/browser/pages/login.css | 14 ++-- src/browser/pages/login.tsx | 25 ++---- src/browser/pages/missing.css | 8 ++ src/browser/pages/missing.tsx | 9 +++ src/common/api.ts | 1 + src/common/util.ts | 13 +++ src/node/api/server.ts | 1 + src/node/app/server.tsx | 42 +++++++--- src/node/entry.ts | 15 +++- src/node/http.ts | 9 ++- src/node/util.ts | 7 -- test/util.test.ts | 8 +- yarn.lock | 126 ++++------------------------- 26 files changed, 289 insertions(+), 267 deletions(-) create mode 100644 src/browser/pages/missing.css create mode 100644 src/browser/pages/missing.tsx diff --git a/package.json b/package.json index 2b61885f..bd51606b 100644 --- a/package.json +++ b/package.json @@ -22,13 +22,13 @@ "devDependencies": { "@coder/nbin": "^1.2.7", "@types/fs-extra": "^8.0.1", + "@types/hookrouter": "^2.2.1", "@types/mocha": "^5.2.7", "@types/node": "^12.12.7", "@types/parcel-bundler": "^1.12.1", "@types/pem": "^1.9.5", "@types/react": "^16.9.18", "@types/react-dom": "^16.9.5", - "@types/react-router-dom": "^5.1.3", "@types/safe-compare": "^1.1.0", "@types/tar-fs": "^1.16.1", "@types/tar-stream": "^1.6.1", @@ -58,11 +58,11 @@ "dependencies": { "@coder/logger": "1.1.11", "fs-extra": "^8.1.0", + "hookrouter": "^1.2.3", "httpolyglot": "^0.1.2", "pem": "^1.14.2", "react": "^16.12.0", "react-dom": "^16.12.0", - "react-router-dom": "^5.1.2", "safe-compare": "^1.1.4", "tar-fs": "^2.0.0", "tar-stream": "^2.1.0", diff --git a/scripts/build.ts b/scripts/build.ts index 617687fc..ef8001d2 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -173,6 +173,21 @@ class Builder { }) await this.copyDependencies("code-server", this.rootPath, this.buildPath) + + await this.task("writing final code-server package.json", async () => { + const json = JSON.parse(await fs.readFile(path.join(this.buildPath, "package.json"), "utf8")) + return fs.writeFile( + path.join(this.buildPath, "package.json"), + JSON.stringify( + { + ...json, + commit, + }, + null, + 2 + ) + ) + }) } private async buildVscode(commit: string): Promise { @@ -369,6 +384,9 @@ class Builder { bundler.on("buildEnd", () => { console.log("[parcel] bundled") }) + bundler.on("buildError", (error) => { + console.error("[parcel]", error) + }) vscode.stderr.on("data", (d) => process.stderr.write(d)) tsc.stderr.on("data", (d) => process.stderr.write(d)) diff --git a/src/browser/app.css b/src/browser/app.css index 6819ea42..039d2e39 100644 --- a/src/browser/app.css +++ b/src/browser/app.css @@ -12,7 +12,12 @@ iframe { body { background: #272727; + color: #f4f4f4; margin: 0; font-family: 'IBM Plex Sans', sans-serif; overflow: hidden; } + +button { + font-family: inherit; +} diff --git a/src/browser/app.tsx b/src/browser/app.tsx index 04201995..e0e78869 100644 --- a/src/browser/app.tsx +++ b/src/browser/app.tsx @@ -1,35 +1,40 @@ +import { getBasepath, navigate } from "hookrouter" import * as React from "react" -import { Application } from "../common/api" -import { Route, Switch } from "react-router-dom" +import { Application, isExecutableApplication } from "../common/api" import { HttpError } from "../common/http" +import { normalize, Options } from "../common/util" import { Modal } from "./components/modal" -import { getOptions } from "../common/util" -const App: React.FunctionComponent = () => { - const [authed, setAuthed] = React.useState(false) - const [app, setApp] = React.useState() +export interface AppProps { + options: Options +} + +const App: React.FunctionComponent = (props) => { + const [authed, setAuthed] = React.useState(!!props.options.authed) + const [app, setApp] = React.useState(props.options.app) const [error, setError] = React.useState() React.useEffect(() => { - getOptions() - }, []) + if (app && !isExecutableApplication(app)) { + navigate(normalize(`${getBasepath()}/${app.path}/`, true)) + } + }, [app]) if (typeof window !== "undefined") { // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(window as any).setAuthed = setAuthed + ;(window as any).setAuthed = (a: boolean): void => { + if (authed !== a) { + setAuthed(a) + } + } } return ( <> - - } /> - ( - - )} - /> - + + {authed && app && app.embedPath ? ( + + ) : null} ) } diff --git a/src/browser/components/animate.tsx b/src/browser/components/animate.tsx index 50a58846..fa866ea7 100644 --- a/src/browser/components/animate.tsx +++ b/src/browser/components/animate.tsx @@ -5,6 +5,10 @@ export interface DelayProps { readonly delay: number } +/** + * Animate a component before unmounting (by delaying unmounting) or after + * mounting. + */ export const Animate: React.FunctionComponent = (props) => { const [timer, setTimer] = React.useState() const [mount, setMount] = React.useState(false) diff --git a/src/browser/components/error.tsx b/src/browser/components/error.tsx index 1fbf14fa..d8326d0a 100644 --- a/src/browser/components/error.tsx +++ b/src/browser/components/error.tsx @@ -4,6 +4,7 @@ import { HttpError } from "../../common/http" export interface ErrorProps { error: HttpError | Error | string onClose?: () => void + onCloseText?: string } /** @@ -15,7 +16,7 @@ export const RequestError: React.FunctionComponent = (props) => {
{typeof props.error === "string" ? props.error : props.error.message}
{props.onClose ? ( ) : ( undefined diff --git a/src/browser/components/list.css b/src/browser/components/list.css index 3b6eea80..bc148fe6 100644 --- a/src/browser/components/list.css +++ b/src/browser/components/list.css @@ -20,7 +20,6 @@ .app-row { color: #b6b6b6; - cursor: pointer; display: flex; font-size: 1em; line-height: 1em; @@ -106,3 +105,7 @@ .app-list > .loader { color: #b6b6b6; } + +.app-list > .app-row { + cursor: pointer; +} diff --git a/src/browser/components/list.tsx b/src/browser/components/list.tsx index 0266a865..5dfa32ce 100644 --- a/src/browser/components/list.tsx +++ b/src/browser/components/list.tsx @@ -4,6 +4,9 @@ import { HttpError } from "../../common/http" import { getSession, killSession } from "../api" import { RequestError } from "../components/error" +/** + * An application's details (name and icon). + */ export const AppDetails: React.FunctionComponent = (props) => { return ( <> @@ -23,6 +26,9 @@ export interface AppRowProps { open(app: Application): void } +/** + * A single application row. Can be killed if it's a running application. + */ export const AppRow: React.FunctionComponent = (props) => { const [killing, setKilling] = React.useState(false) const [error, setError] = React.useState() @@ -65,6 +71,11 @@ export interface AppListProps { onKilled(app: Application): void } +/** + * A list of applications. If undefined, show loading text. If empty, show a + * message saying no items are found. Applications can be clicked and killed + * (when applicable). + */ export const AppList: React.FunctionComponent = (props) => { return (
@@ -92,11 +103,12 @@ export interface AppLoaderProps { } /** - * Display provided applications or sessions and allow opening them. + * Application sections/groups. Handles loading of the application + * sections, errors, opening applications, and killing applications. */ export const AppLoader: React.FunctionComponent = (props) => { const [apps, setApps] = React.useState>() - const [error, setError] = React.useState() + const [error, setError] = React.useState() const refresh = (): void => { props @@ -105,33 +117,51 @@ export const AppLoader: React.FunctionComponent = (props) => { .catch((e) => setError(e.message)) } + // Every time the component loads go ahead and refresh the list. React.useEffect(() => { refresh() }, [props]) + /** + * Open an application if not already open. For executable applications create + * a session first. + */ function open(app: Application): void { + if (props.app && props.app.name === app.name) { + return setError(new Error(`${app.name} is already open`)) + } props.setApp(app) if (!isRunningApplication(app) && isExecutableApplication(app)) { getSession(app) .then((session) => { props.setApp({ ...app, ...session }) }) - .catch(setError) + .catch((error) => { + props.setApp(undefined) + setError(error) + }) } } + // In the case of an error fetching the apps, have the ability to try again. + // In the case of failing to load an app, have the ability to go back to the + // list (where the user can try again if they wish). if (error) { - props.setApp(undefined) return ( { setError(undefined) + if (!props.app) { + refresh() + } }} /> ) } + // If an app is currently loading, provide the option to cancel. if (props.app && !props.app.loaded) { return (
@@ -151,14 +181,16 @@ export const AppLoader: React.FunctionComponent = (props) => { ) } + // Apps are currently loading. if (!apps) { return (
-
loading
+
loading...
) } + // Apps have loaded. return ( <> {apps.map((section, i) => ( diff --git a/src/browser/components/modal.css b/src/browser/components/modal.css index 60bcc343..bedc74f8 100644 --- a/src/browser/components/modal.css +++ b/src/browser/components/modal.css @@ -100,19 +100,22 @@ width: 100%; } -.modal-container > .modal > .sidebar { - border-right: 1.5px solid rgba(0, 0, 0, 0.37); +.sidebar-nav { display: flex; flex-direction: column; justify-content: space-between; + min-width: 145px; } -.modal-container > .modal > .sidebar > .links { +.sidebar-nav > .links { display: flex; flex-direction: column; } -.modal-container > .modal > .sidebar > .links > .link { +.sidebar-nav > .links > .link { + background-color: transparent; + border: none; + cursor: pointer; color: rgba(0, 0, 0, 0.37); font-size: 1.4em; height: 31px; @@ -122,7 +125,7 @@ transition: 150ms color ease, 150ms height ease, 150ms margin-bottom ease; } -.modal-container > .modal > .sidebar > .footer > .close { +.sidebar-nav > .footer > .close { background: transparent; border: none; color: #b6b6b6; @@ -130,14 +133,12 @@ width: 100%; } -.modal-container > .modal > .sidebar > .footer > .close:hover { +.sidebar-nav > .links > .link[aria-current="page"], +.sidebar-nav > .links > .link:hover, +.sidebar-nav > .footer > .close:hover { color: #000; } -.modal-container > .modal > .links > .link[aria-current="page"] { - color: rgba(0, 0, 0, 1); -} - .modal-container > .modal > .content { display: flex; flex: 1; @@ -145,3 +146,7 @@ overflow: auto; padding: 0 20px; } + +.modal-container > .modal > .sidebar-nav { + border-right: 1.5px solid rgba(0, 0, 0, 0.37); +} diff --git a/src/browser/components/modal.tsx b/src/browser/components/modal.tsx index 7b4df2c0..d57afc18 100644 --- a/src/browser/components/modal.tsx +++ b/src/browser/components/modal.tsx @@ -1,12 +1,12 @@ import { logger } from "@coder/logger" import * as React from "react" -import { NavLink, Route, RouteComponentProps, Switch } from "react-router-dom" import { Application, isExecutableApplication } from "../../common/api" import { HttpError } from "../../common/http" import { RequestError } from "../components/error" import { Browse } from "../pages/browse" import { Home } from "../pages/home" import { Login } from "../pages/login" +import { Missing } from "../pages/missing" import { Open } from "../pages/open" import { Recent } from "../pages/recent" import { Animate } from "./animate" @@ -19,9 +19,18 @@ export interface ModalProps { setError(error?: HttpError | Error | string): void } +enum Section { + Browse, + Home, + Login, + Open, + Recent, +} + export const Modal: React.FunctionComponent = (props) => { const [showModal, setShowModal] = React.useState(false) - const [showBar, setShowBar] = React.useState(true) + const [showBar, setShowBar] = React.useState(false) // TEMP: Will be true. + const [section, setSection] = React.useState
(Section.Home) const setApp = (app: Application): void => { setShowModal(false) @@ -36,7 +45,8 @@ export const Modal: React.FunctionComponent = (props) => { clearTimeout(timeout) timeout = undefined } else if (clientY <= 30 && !timeout) { - timeout = setTimeout(() => setShowBar(true), 1000) + // TEMP: No bar for now. + // timeout = setTimeout(() => setShowBar(true), 1000) } } @@ -91,39 +101,49 @@ export const Modal: React.FunctionComponent = (props) => { } }, [showBar, props.error, showModal, props.app]) + const content = (): React.ReactElement => { + if (!props.authed) { + return + } + switch (section) { + case Section.Recent: + return + case Section.Home: + return + case Section.Browse: + return + case Section.Login: + return + case Section.Open: + return + default: + return + } + } + return props.error || showModal || !props.app || !props.app.loaded ? (
{props.authed && (!props.app || props.app.loaded) ? ( -
diff --git a/src/browser/index.tsx b/src/browser/index.tsx index 60b6b382..0d31b11d 100644 --- a/src/browser/index.tsx +++ b/src/browser/index.tsx @@ -1,18 +1,14 @@ import * as React from "react" import * as ReactDOM from "react-dom" import App from "./app" -import { BrowserRouter } from "react-router-dom" +import { getOptions } from "../common/util" import "./app.css" import "./pages/home.css" import "./pages/login.css" +import "./pages/missing.css" import "./components/error.css" import "./components/list.css" import "./components/modal.css" -ReactDOM.hydrate( - - - , - document.getElementById("root") -) +ReactDOM.hydrate(, document.getElementById("root")) diff --git a/src/browser/pages/browse.tsx b/src/browser/pages/browse.tsx index cc362fd1..6b646558 100644 --- a/src/browser/pages/browse.tsx +++ b/src/browser/pages/browse.tsx @@ -1,5 +1,4 @@ import * as React from "react" -import { RouteComponentProps } from "react-router" import { FilesResponse } from "../../common/api" import { HttpError } from "../../common/http" import { getFiles } from "../api" @@ -8,14 +7,14 @@ import { RequestError } from "../components/error" /** * File browser. */ -export const Browse: React.FunctionComponent = (props) => { +export const Browse: React.FunctionComponent = (props) => { const [response, setResponse] = React.useState() const [error, setError] = React.useState() React.useEffect(() => { getFiles() .then(setResponse) - .catch((e) => setError(e.message)) + .catch(setError) }, [props]) return ( diff --git a/src/browser/pages/home.tsx b/src/browser/pages/home.tsx index 8c10548b..a2d38c5e 100644 --- a/src/browser/pages/home.tsx +++ b/src/browser/pages/home.tsx @@ -1,22 +1,20 @@ import * as React from "react" -import { RouteComponentProps } from "react-router" +import { Application } from "../../common/api" import { authenticate } from "../api" -export const Home: React.FunctionComponent = (props) => { +export interface HomeProps { + app?: Application +} + +export const Home: React.FunctionComponent = (props) => { React.useEffect(() => { - authenticate() - .then(() => { - // TEMP: Always redirect to VS Code. - props.history.push("./vscode/") - }) - .catch(() => { - props.history.push("./login/") - }) + authenticate().catch(() => undefined) }, []) return (
Welcome to code-server.
+ {props.app && !props.app.loaded ?
loading...
: undefined}
) } diff --git a/src/browser/pages/login.css b/src/browser/pages/login.css index 77bf72f3..1ba46cf7 100644 --- a/src/browser/pages/login.css +++ b/src/browser/pages/login.css @@ -4,9 +4,7 @@ display: flex; flex: 1; flex-direction: column; - font-weight: 700; justify-content: center; - text-transform: uppercase; } .login-form > .field { @@ -16,13 +14,13 @@ } .login-form > .field-error { - margin-top: 10px; + margin-top: 1em; } .login-form > .field > .input { border: 1px solid #b6b6b6; box-sizing: border-box; - padding: 10px; + padding: 1em; flex: 1; } @@ -31,5 +29,11 @@ border: 1px solid #b6b6b6; box-sizing: border-box; margin-left: -1px; - padding: 10px 20px; + padding: 1em 2em; +} + +.login-header { + align-items: center; + color: #b6b6b6; + margin-bottom: 1em; } diff --git a/src/browser/pages/login.tsx b/src/browser/pages/login.tsx index a2cdec4e..03340b0a 100644 --- a/src/browser/pages/login.tsx +++ b/src/browser/pages/login.tsx @@ -1,5 +1,4 @@ import * as React from "react" -import { RouteComponentProps } from "react-router" import { HttpError } from "../../common/http" import { authenticate } from "../api" import { FieldError } from "../components/error" @@ -7,35 +6,25 @@ import { FieldError } from "../components/error" /** * Login page. Will redirect on success. */ -export const Login: React.FunctionComponent = (props) => { +export const Login: React.FunctionComponent = () => { const [password, setPassword] = React.useState("") const [error, setError] = React.useState() - function redirect(): void { - // TEMP: Always redirect to VS Code. - console.log("is authed") - props.history.push("../vscode/") - // const params = new URLSearchParams(window.location.search) - // props.history.push(params.get("to") || "/") - } - async function handleSubmit(event: React.FormEvent): Promise { event.preventDefault() - authenticate({ password }) - .then(redirect) - .catch(setError) + authenticate({ password }).catch(setError) } React.useEffect(() => { - authenticate() - .then(redirect) - .catch(() => { - // Do nothing; we're already at the login page. - }) + authenticate().catch(() => undefined) }, []) return (
+
+
Welcome to code-server
+
Please log in below
+
{ + return ( +
+
404
+
+ ) +} diff --git a/src/common/api.ts b/src/common/api.ts index 2763f7fd..6bca12f5 100644 --- a/src/common/api.ts +++ b/src/common/api.ts @@ -1,6 +1,7 @@ export interface Application { readonly comment?: string readonly directory?: string + readonly embedPath?: string readonly exec?: string readonly icon?: string readonly loaded?: boolean diff --git a/src/common/util.ts b/src/common/util.ts index 8394e5f3..9e39532d 100644 --- a/src/common/util.ts +++ b/src/common/util.ts @@ -1,6 +1,9 @@ import { logger } from "@coder/logger" +import { Application } from "../common/api" export interface Options { + app?: Application + authed?: boolean logLevel?: number } @@ -27,6 +30,9 @@ export const generateUuid = (length = 24): string => { * Get options embedded in the HTML from the server. */ export const getOptions = (): T => { + if (typeof document === "undefined") { + return {} as T + } const el = document.getElementById("coder-options") try { if (!el) { @@ -46,3 +52,10 @@ export const getOptions = (): T => { return {} as T } } + +/** + * Remove extra slashes in a URL. + */ +export const normalize = (url: string, keepTrailing = false): string => { + return url.replace(/\/\/+/g, "/").replace(/\/+$/, keepTrailing ? "/" : "") +} diff --git a/src/node/api/server.ts b/src/node/api/server.ts index 7c3e6c8a..f6b8b476 100644 --- a/src/node/api/server.ts +++ b/src/node/api/server.ts @@ -151,6 +151,7 @@ export class ApiHttpProvider extends HttpProvider { { name: "VS Code", path: "/vscode", + embedPath: "/vscode-embed", }, ], }, diff --git a/src/node/app/server.tsx b/src/node/app/server.tsx index 6d928a25..2d42ca26 100644 --- a/src/node/app/server.tsx +++ b/src/node/app/server.tsx @@ -1,33 +1,49 @@ import { logger } from "@coder/logger" +import * as http from "http" +import * as querystring from "querystring" import * as React from "react" import * as ReactDOMServer from "react-dom/server" -import * as ReactRouterDOM from "react-router-dom" import App from "../../browser/app" +import { Options } from "../../common/util" import { HttpProvider, HttpResponse } from "../http" /** * Top-level and fallback HTTP provider. */ export class MainHttpProvider extends HttpProvider { - public async handleRequest(base: string, requestPath: string): Promise { + public async handleRequest( + base: string, + requestPath: string, + _query: querystring.ParsedUrlQuery, + request: http.IncomingMessage + ): Promise { if (base === "/static") { const response = await this.getResource(this.rootPath, requestPath) - response.cache = true + if (this.options.commit && this.options.commit !== "development") { + response.cache = true + } return response } + // TEMP: Auto-load VS Code for now. In future versions we'll need to check + // the URL for the appropriate application to load, if any. + const app = { + name: "VS Code", + path: "/", + embedPath: "/vscode-embed", + } + + const options: Options = { + app, + authed: !!this.authenticated(request), + logLevel: logger.level, + } + const response = await this.getUtf8Resource(this.rootPath, "src/browser/index.html") response.content = response.content - .replace(/{{COMMIT}}/g, "") // TODO - .replace(/"{{OPTIONS}}"/g, `'${JSON.stringify({ logLevel: logger.level })}'`) - .replace( - /{{COMPONENT}}/g, - ReactDOMServer.renderToString( - - - - ) - ) + .replace(/{{COMMIT}}/g, this.options.commit || "development") + .replace(/"{{OPTIONS}}"/g, `'${JSON.stringify(options)}'`) + .replace(/{{COMPONENT}}/g, ReactDOMServer.renderToString()) return response } diff --git a/src/node/entry.ts b/src/node/entry.ts index fa9a9916..34f0a2e6 100644 --- a/src/node/entry.ts +++ b/src/node/entry.ts @@ -23,25 +23,32 @@ const main = async (args: Args = {}): Promise => { const auth = args.auth || AuthType.Password const originalPassword = auth === AuthType.Password && (process.env.PASSWORD || (await generatePassword())) + let commit = "development" + try { + commit = require("../../package.json").commit + } catch (error) { + logger.warn(error.message) + } + // Spawn the main HTTP server. const options = { + auth, basePath: args["base-path"], cert: args.cert, certKey: args["cert-key"], + commit, host: args.host || (args.auth === AuthType.Password && typeof args.cert !== "undefined" ? "0.0.0.0" : "localhost"), + password: originalPassword ? hash(originalPassword) : undefined, port: typeof args.port !== "undefined" ? parseInt(args.port, 10) : 8080, socket: args.socket, - auth, - password: originalPassword ? hash(originalPassword) : undefined, } if (!options.cert && typeof options.cert !== "undefined") { const { cert, certKey } = await generateCertificate() options.cert = cert options.certKey = certKey } - const httpServer = new HttpServer(options) - // Register all the providers. + const httpServer = new HttpServer(options) httpServer.registerHttpProvider("/", MainHttpProvider) httpServer.registerHttpProvider("/api", ApiHttpProvider, httpServer) httpServer.registerHttpProvider("/vscode-embed", VscodeHttpProvider, []) diff --git a/src/node/http.ts b/src/node/http.ts index 1588776e..df0b89d0 100644 --- a/src/node/http.ts +++ b/src/node/http.ts @@ -12,8 +12,8 @@ import * as tarFs from "tar-fs" import * as tls from "tls" import * as url from "url" import { HttpCode, HttpError } from "../common/http" -import { plural, split } from "../common/util" -import { getMediaMime, normalize, xdgLocalDir } from "./util" +import { normalize, plural, split } from "../common/util" +import { getMediaMime, xdgLocalDir } from "./util" export type Cookies = { [key: string]: string[] | undefined } export type PostData = { [key: string]: string | string[] | undefined } @@ -92,6 +92,7 @@ export interface HttpServerOptions { readonly basePath?: string readonly cert?: string readonly certKey?: string + readonly commit?: string readonly host?: string readonly password?: string readonly port?: number @@ -111,6 +112,7 @@ export interface HttpProviderOptions { readonly base: string readonly auth: AuthType readonly password?: string + readonly commit?: string } /** @@ -120,7 +122,7 @@ export interface HttpProviderOptions { export abstract class HttpProvider { protected readonly rootPath = path.resolve(__dirname, "../..") - public constructor(private readonly options: HttpProviderOptions) {} + public constructor(protected readonly options: HttpProviderOptions) {} public dispose(): void { // No default behavior. @@ -403,6 +405,7 @@ export class HttpServer { { auth: this.options.auth || AuthType.None, base: endpoint, + commit: this.options.commit, password: this.options.password, }, a1 diff --git a/src/node/util.ts b/src/node/util.ts index 599ad897..e1ae65f2 100644 --- a/src/node/util.ts +++ b/src/node/util.ts @@ -207,10 +207,3 @@ export function extend(...args: any[]): any { } return c } - -/** - * Remove extra and trailing slashes in a URL. - */ -export const normalize = (url: string): string => { - return url.replace(/\/\/+/g, "/").replace(/\/+$/, "") -} diff --git a/test/util.test.ts b/test/util.test.ts index 6a9c3a3c..28fb9fc8 100644 --- a/test/util.test.ts +++ b/test/util.test.ts @@ -1,5 +1,6 @@ import * as assert from "assert" -import { extend, normalize } from "../src/node/util" +import { normalize } from "../src/common/util" +import { extend } from "../src/node/util" describe("util", () => { describe("extend", () => { @@ -45,5 +46,10 @@ describe("util", () => { it("should remove trailing slashes", () => { assert.equal(normalize("qux///"), "qux") }) + + it("should preserve trailing slash if it exists", () => { + assert.equal(normalize("qux///", true), "qux/") + assert.equal(normalize("qux", true), "qux") + }) }) }) diff --git a/yarn.lock b/yarn.lock index 887738c6..51115d6a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -714,7 +714,7 @@ levenary "^1.1.1" semver "^5.5.0" -"@babel/runtime@^7.1.2", "@babel/runtime@^7.4.0", "@babel/runtime@^7.4.4", "@babel/runtime@^7.6.3": +"@babel/runtime@^7.4.4", "@babel/runtime@^7.6.3": version "7.8.4" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.8.4.tgz#d79f5a2040f7caa24d53e563aad49cbc05581308" integrity sha512-neAp3zt80trRVBI1x0azq6c57aNBqYZH8KhMm3TaB7wEI5Q4A2SHfBHE8w9gOhI/lrqxtEbXZgQIrHP+wvSGwQ== @@ -881,10 +881,13 @@ dependencies: "@types/node" "*" -"@types/history@*": - version "4.7.5" - resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.5.tgz#527d20ef68571a4af02ed74350164e7a67544860" - integrity sha512-wLD/Aq2VggCJXSjxEwrMafIP51Z+13H78nXIX0ABEuIGhmB5sNGbR113MOKo+yfw+RDo1ZU3DM6yfnnRF/+ouw== +"@types/hookrouter@^2.2.1": + version "2.2.1" + resolved "https://registry.yarnpkg.com/@types/hookrouter/-/hookrouter-2.2.1.tgz#0c69e671957b48ade9e042612faf3fc833a3fd59" + integrity sha512-C4Ae6yf8vE4TEKZa0EpP2o85UMVHKsZeJwT0oWiQ1QkNBbkQ8bk5A+qWS25Uect9r1Uivz/dnSg6v4WwzFjgrg== + dependencies: + "@types/react" "*" + csstype "^2.2.0" "@types/json-schema@^7.0.3": version "7.0.4" @@ -952,23 +955,6 @@ dependencies: "@types/react" "*" -"@types/react-router-dom@^5.1.3": - version "5.1.3" - resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.1.3.tgz#b5d28e7850bd274d944c0fbbe5d57e6b30d71196" - integrity sha512-pCq7AkOvjE65jkGS5fQwQhvUp4+4PVD9g39gXLZViP2UqFiFzsEpB3PKf0O6mdbKsewSK8N14/eegisa/0CwnA== - dependencies: - "@types/history" "*" - "@types/react" "*" - "@types/react-router" "*" - -"@types/react-router@*": - version "5.1.4" - resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.1.4.tgz#7d70bd905543cb6bcbdcc6bd98902332054f31a6" - integrity sha512-PZtnBuyfL07sqCJvGg3z+0+kt6fobc/xmle08jBiezLS8FrmGeiGkJnuxL/8Zgy9L83ypUhniV5atZn/L8n9MQ== - dependencies: - "@types/history" "*" - "@types/react" "*" - "@types/react@*", "@types/react@^16.9.18": version "16.9.19" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.19.tgz#c842aa83ea490007d29938146ff2e4d9e4360c40" @@ -3460,11 +3446,6 @@ growl@1.10.5: resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" integrity sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA== -gud@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/gud/-/gud-1.0.0.tgz#a489581b17e6a70beca9abe3ae57de7a499852c0" - integrity sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw== - har-schema@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" @@ -3574,18 +3555,6 @@ hex-color-regex@^1.1.0: resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== -history@^4.9.0: - version "4.10.1" - resolved "https://registry.yarnpkg.com/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" - integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew== - dependencies: - "@babel/runtime" "^7.1.2" - loose-envify "^1.2.0" - resolve-pathname "^3.0.0" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" - value-equal "^1.0.1" - hmac-drbg@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" @@ -3595,12 +3564,10 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.1.0: - version "3.3.2" - resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" - integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== - dependencies: - react-is "^16.7.0" +hookrouter@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/hookrouter/-/hookrouter-1.2.3.tgz#a65599a1be376b51734caf7c4f7f8aba59bb2c77" + integrity sha512-n0mqEBGgXIxYRNMHlwCzoyTOk0OB5Es3jwUyA3+2l5nte/52n0CMMj1bmoCabC8K43YTUEr0zzexTBfo//tq2Q== hosted-git-info@^2.1.4: version "2.8.5" @@ -4461,7 +4428,7 @@ longest-streak@^2.0.1: resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-2.0.4.tgz#b8599957da5b5dab64dee3fe316fa774597d90e4" integrity sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg== -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -4643,15 +4610,6 @@ min-indent@^1.0.0: resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.0.tgz#cfc45c37e9ec0d8f0a0ec3dd4ef7f7c3abe39256" integrity sha1-z8RcN+nsDY8KDsPdTvf3w6vjklY= -mini-create-react-context@^0.3.0: - version "0.3.2" - resolved "https://registry.yarnpkg.com/mini-create-react-context/-/mini-create-react-context-0.3.2.tgz#79fc598f283dd623da8e088b05db8cddab250189" - integrity sha512-2v+OeetEyliMt5VHMXsBhABoJ0/M4RCe7fatd/fBy6SMiKazUSEt3gxxypfnk2SHMkdBYvorHRoQxuGoiwbzAw== - dependencies: - "@babel/runtime" "^7.4.0" - gud "^1.0.0" - tiny-warning "^1.0.2" - minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" @@ -5342,13 +5300,6 @@ path-platform@~0.11.15: resolved "https://registry.yarnpkg.com/path-platform/-/path-platform-0.11.15.tgz#e864217f74c36850f0852b78dc7bf7d4a5721bf2" integrity sha1-6GQhf3TDaFDwhSt43Hv31KVyG/I= -path-to-regexp@^1.7.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" - integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA== - dependencies: - isarray "0.0.1" - path-type@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73" @@ -6031,40 +5982,11 @@ react-dom@^16.12.0: prop-types "^15.6.2" scheduler "^0.18.0" -react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1: +react-is@^16.8.1: version "16.12.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c" integrity sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q== -react-router-dom@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.1.2.tgz#06701b834352f44d37fbb6311f870f84c76b9c18" - integrity sha512-7BPHAaIwWpZS074UKaw1FjVdZBSVWEk8IuDXdB+OkLb8vd/WRQIpA4ag9WQk61aEfQs47wHyjWUoUGGZxpQXew== - dependencies: - "@babel/runtime" "^7.1.2" - history "^4.9.0" - loose-envify "^1.3.1" - prop-types "^15.6.2" - react-router "5.1.2" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" - -react-router@5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.1.2.tgz#6ea51d789cb36a6be1ba5f7c0d48dd9e817d3418" - integrity sha512-yjEuMFy1ONK246B+rsa0cUam5OeAQ8pyclRDgpxuSCrAlJ1qN9uZ5IgyKC7gQg0w8OM50NXHEegPh/ks9YuR2A== - dependencies: - "@babel/runtime" "^7.1.2" - history "^4.9.0" - hoist-non-react-statics "^3.1.0" - loose-envify "^1.3.1" - mini-create-react-context "^0.3.0" - path-to-regexp "^1.7.0" - prop-types "^15.6.2" - react-is "^16.6.0" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" - react@^16.12.0: version "16.12.0" resolved "https://registry.yarnpkg.com/react/-/react-16.12.0.tgz#0c0a9c6a142429e3614834d5a778e18aa78a0b83" @@ -6402,11 +6324,6 @@ resolve-from@^5.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== -resolve-pathname@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/resolve-pathname/-/resolve-pathname-3.0.0.tgz#99d02224d3cf263689becbb393bc560313025dcd" - integrity sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng== - resolve-url@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" @@ -7321,16 +7238,6 @@ tiny-inflate@^1.0.0: resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4" integrity sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw== -tiny-invariant@^1.0.2: - version "1.1.0" - resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875" - integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw== - -tiny-warning@^1.0.0, tiny-warning@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" - integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== - tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" @@ -7763,11 +7670,6 @@ validate-npm-package-license@^3.0.1: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" -value-equal@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" - integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== - vendors@^1.0.0: version "1.0.4" resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.4.tgz#e2b800a53e7a29b93506c3cf41100d16c4c4ad8e"