Archived
1
0

Simplify frontend

Just a login form and a list of applications. No modals or anything like
that.
This commit is contained in:
Asher 2020-02-13 16:38:05 -06:00
parent bf1be16d11
commit b8fa7da972
No known key found for this signature in database
GPG Key ID: D63C1EF81242354A
49 changed files with 984 additions and 1846 deletions

View File

@ -8,32 +8,16 @@ env:
parserOptions: parserOptions:
ecmaVersion: 2018 ecmaVersion: 2018
sourceType: module sourceType: module
ecmaFeatures:
jsx: true
extends: extends:
- eslint:recommended - eslint:recommended
- plugin:@typescript-eslint/recommended - plugin:@typescript-eslint/recommended
- plugin:import/recommended - plugin:import/recommended
- plugin:import/typescript - plugin:import/typescript
- plugin:react/recommended
- plugin:prettier/recommended - plugin:prettier/recommended
- prettier # Removes eslint rules that conflict with prettier. - prettier # Removes eslint rules that conflict with prettier.
- prettier/@typescript-eslint # Remove conflicts again. - prettier/@typescript-eslint # Remove conflicts again.
plugins:
- react-hooks
# Need to set this explicitly for the eslint-plugin-react.
settings:
react:
version: detect
rules: rules:
# For overloads. # For overloads.
no-dupe-class-members: off no-dupe-class-members: off
# https://www.npmjs.com/package/eslint-plugin-react-hooks
react-hooks/rules-of-hooks: error
react/prop-types: off # We use Typescript to verify prop types.

View File

@ -22,13 +22,10 @@
"devDependencies": { "devDependencies": {
"@coder/nbin": "^1.2.7", "@coder/nbin": "^1.2.7",
"@types/fs-extra": "^8.0.1", "@types/fs-extra": "^8.0.1",
"@types/hookrouter": "^2.2.1",
"@types/mocha": "^5.2.7", "@types/mocha": "^5.2.7",
"@types/node": "^12.12.7", "@types/node": "^12.12.7",
"@types/parcel-bundler": "^1.12.1", "@types/parcel-bundler": "^1.12.1",
"@types/pem": "^1.9.5", "@types/pem": "^1.9.5",
"@types/react": "^16.9.18",
"@types/react-dom": "^16.9.5",
"@types/safe-compare": "^1.1.0", "@types/safe-compare": "^1.1.0",
"@types/tar-fs": "^1.16.1", "@types/tar-fs": "^1.16.1",
"@types/tar-stream": "^1.6.1", "@types/tar-stream": "^1.6.1",
@ -39,8 +36,6 @@
"eslint-config-prettier": "^6.0.0", "eslint-config-prettier": "^6.0.0",
"eslint-plugin-import": "^2.18.2", "eslint-plugin-import": "^2.18.2",
"eslint-plugin-prettier": "^3.1.0", "eslint-plugin-prettier": "^3.1.0",
"eslint-plugin-react": "^7.14.3",
"eslint-plugin-react-hooks": "^1.7.0",
"leaked-handles": "^5.2.0", "leaked-handles": "^5.2.0",
"mocha": "^6.2.0", "mocha": "^6.2.0",
"parcel-bundler": "^1.12.4", "parcel-bundler": "^1.12.4",
@ -58,11 +53,8 @@
"dependencies": { "dependencies": {
"@coder/logger": "1.1.11", "@coder/logger": "1.1.11",
"fs-extra": "^8.1.0", "fs-extra": "^8.1.0",
"hookrouter": "^1.2.3",
"httpolyglot": "^0.1.2", "httpolyglot": "^0.1.2",
"pem": "^1.14.2", "pem": "^1.14.2",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"safe-compare": "^1.1.4", "safe-compare": "^1.1.4",
"tar-fs": "^2.0.0", "tar-fs": "^2.0.0",
"tar-stream": "^2.1.0", "tar-stream": "^2.1.0",

View File

@ -247,7 +247,10 @@ class Builder {
if (process.env.MINIFY) { if (process.env.MINIFY) {
await this.task(`restricting ${name} to production dependencies`, async () => { await this.task(`restricting ${name} to production dependencies`, async () => {
return util.promisify(cp.exec)("yarn --production --ignore-scripts", { cwd: buildPath }) await util.promisify(cp.exec)("yarn --production --ignore-scripts", { cwd: buildPath })
if (name === "code-server") {
await util.promisify(cp.exec)("yarn postinstall", { cwd: buildPath })
}
}) })
} }
} }
@ -419,7 +422,7 @@ class Builder {
} }
private createBundler(out = "dist", commit?: string): Bundler { private createBundler(out = "dist", commit?: string): Bundler {
return new Bundler(path.join(this.rootPath, "src/browser/index.tsx"), { return new Bundler(path.join(this.rootPath, "src/browser/pages/app.ts"), {
cache: true, cache: true,
cacheDir: path.join(this.rootPath, ".cache"), cacheDir: path.join(this.rootPath, ".cache"),
detailedReport: true, detailedReport: true,

9
scripts/code-server.sh Executable file
View File

@ -0,0 +1,9 @@
#!/usr/bin/env bash
# code-server.sh -- Run code-server with the bundled Node binary.
main() {
cd "$(dirname "$0")" || exit 1
./node ./out/node/entry.js "$@"
}
main "$@"

View File

@ -1,82 +0,0 @@
import { getBasepath } from "hookrouter"
import {
Application,
ApplicationsResponse,
CreateSessionResponse,
FilesResponse,
LoginResponse,
RecentResponse,
} from "../common/api"
import { ApiEndpoint, HttpCode, HttpError } from "../common/http"
export interface AuthBody {
password: string
}
/**
* Set authenticated status.
*/
export function setAuthed(authed: boolean): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(window as any).setAuthed(authed)
}
/**
* Try making a request. Throw an error if the request is anything except OK.
* Also set authed to false if the request returns unauthorized.
*/
const tryRequest = async (endpoint: string, options?: RequestInit): Promise<Response> => {
const response = await fetch(getBasepath() + "/api" + endpoint + "/", options)
if (response.status === HttpCode.Unauthorized) {
setAuthed(false)
}
if (response.status !== HttpCode.Ok) {
const text = await response.text()
throw new HttpError(text || response.statusText || "unknown error", response.status)
}
return response
}
/**
* Try authenticating.
*/
export const authenticate = async (body?: AuthBody): Promise<LoginResponse> => {
const response = await tryRequest(ApiEndpoint.login, {
method: "POST",
body: JSON.stringify({ ...body, basePath: getBasepath() }),
headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
},
})
return response.json()
}
export const getFiles = async (): Promise<FilesResponse> => {
const response = await tryRequest(ApiEndpoint.files)
return response.json()
}
export const getRecent = async (): Promise<RecentResponse> => {
const response = await tryRequest(ApiEndpoint.recent)
return response.json()
}
export const getApplications = async (): Promise<ApplicationsResponse> => {
const response = await tryRequest(ApiEndpoint.applications)
return response.json()
}
export const getSession = async (app: Application): Promise<CreateSessionResponse> => {
const response = await tryRequest(ApiEndpoint.session, {
method: "POST",
body: JSON.stringify(app),
})
return response.json()
}
export const killSession = async (app: Application): Promise<Response> => {
return tryRequest(ApiEndpoint.session, {
method: "DELETE",
body: JSON.stringify(app),
})
}

View File

@ -1,72 +0,0 @@
import { field, logger } from "@coder/logger"
import { getBasepath, navigate, setBasepath } from "hookrouter"
import * as React from "react"
import { Application, isExecutableApplication } from "../common/api"
import { HttpError } from "../common/http"
import { normalize, Options } from "../common/util"
import { Logo } from "./components/logo"
import { Modal } from "./components/modal"
export interface AppProps {
options: Options
}
interface RedirectedApplication extends Application {
redirected?: boolean
}
let resolved = false
const App: React.FunctionComponent<AppProps> = (props) => {
const [authed, setAuthed] = React.useState<boolean>(props.options.authed)
const [app, setApp] = React.useState<RedirectedApplication | undefined>(props.options.app)
const [error, setError] = React.useState<HttpError | Error | string>()
if (!resolved && typeof document !== "undefined") {
// Get the base path. We need the full URL for connecting the web socket.
// Use the path name plus the provided base path. For example:
// foo.com/base + ./ => foo.com/base
// foo.com/base/ + ./ => foo.com/base
// foo.com/base/bar + ./ => foo.com/base
// foo.com/base/bar/ + ./../ => foo.com/base
const parts = window.location.pathname.replace(/^\//g, "").split("/")
parts[parts.length - 1] = props.options.basePath
const url = new URL(window.location.origin + "/" + parts.join("/"))
setBasepath(normalize(url.pathname))
logger.debug("resolved base path", field("base", getBasepath()))
resolved = true
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(window as any).setAuthed = (a: boolean): void => {
if (authed !== a) {
setAuthed(a)
}
}
}
React.useEffect(() => {
if (app && !isExecutableApplication(app) && !app.redirected) {
navigate(normalize(`${getBasepath()}/${app.path}/`, true))
setApp({ ...app, redirected: true })
}
}, [app])
return (
<>
{!app || !app.loaded ? (
<div className="coder-splash">
<Logo />
</div>
) : (
undefined
)}
<Modal app={app} setApp={setApp} authed={authed} error={error} setError={setError} />
{authed && app && app.embedPath && app.redirected ? (
<iframe id="iframe" src={normalize(`./${app.embedPath}/`, true)}></iframe>
) : (
undefined
)}
</>
)
}
export default App

View File

@ -1,31 +0,0 @@
import * as React from "react"
export interface DelayProps {
readonly show: boolean
readonly delay: number
}
/**
* Animate a component before unmounting (by delaying unmounting) or after
* mounting.
*/
export const Animate: React.FunctionComponent<DelayProps> = (props) => {
const [timer, setTimer] = React.useState<NodeJS.Timeout>()
const [mount, setMount] = React.useState<boolean>(false)
const [visible, setVisible] = React.useState<boolean>(false)
React.useEffect(() => {
if (timer) {
clearTimeout(timer)
}
if (!props.show) {
setVisible(false)
setTimer(setTimeout(() => setMount(false), props.delay))
} else {
setTimer(setTimeout(() => setVisible(true), props.delay))
setMount(true)
}
}, [props])
return mount ? <div className={`animate -${visible ? "show" : "hide"}`}>{props.children}</div> : null
}

View File

@ -1,28 +0,0 @@
.field-error {
color: red;
}
.request-error {
align-items: center;
color: rgba(0, 0, 0, 0.37);
display: flex;
flex: 1;
flex-direction: column;
font-weight: 700;
justify-content: center;
padding: 20px;
text-transform: uppercase;
}
.request-error > .close {
background: transparent;
border: none;
color: #b6b6b6;
cursor: pointer;
margin-top: 10px;
width: 100%;
}
.request-error + .request-error {
border-top: 1px solid #b6b6b6;
}

View File

@ -1,49 +0,0 @@
import * as React from "react"
import { HttpError } from "../../common/http"
export interface ErrorProps {
error: HttpError | Error | string
onClose?: () => void
onCloseText?: string
}
/**
* An error to be displayed in a section where a request has failed.
*/
export const RequestError: React.FunctionComponent<ErrorProps> = (props) => {
return (
<div className="request-error">
<div className="error">{typeof props.error === "string" ? props.error : props.error.message}</div>
{props.onClose ? (
<button className="close" onClick={props.onClose}>
{props.onCloseText || "Close"}
</button>
) : (
undefined
)}
</div>
)
}
/**
* Return a more human/natural/useful message for some error codes resulting
* from a form submission.
*/
const humanizeFormError = (error: HttpError | Error | string): string => {
if (typeof error === "string") {
return error
}
switch ((error as HttpError).code) {
case 401:
return "Wrong password"
default:
return error.message
}
}
/**
* An error to be displayed underneath a field.
*/
export const FieldError: React.FunctionComponent<ErrorProps> = (props) => {
return <div className="field-error">{humanizeFormError(props.error)}</div>
}

View File

@ -1,111 +0,0 @@
.app-list {
list-style-type: none;
padding: 0;
margin: 0 -10px; /* To counter app padding. */
flex: 1;
}
.app-loader {
align-items: center;
color: #b6b6b6;
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
}
.app-loader > .loader {
color: #b6b6b6;
}
.app-row {
color: #b6b6b6;
display: flex;
font-size: 1em;
line-height: 1em;
width: 100%;
}
.app-row > .launch,
.app-row > .kill {
background-color: transparent;
border: none;
color: inherit;
cursor: pointer;
font-size: 1em;
line-height: 1em;
margin: 1px 0;
padding: 3px 10px;
}
.app-row > .launch {
border-radius: 50px;
display: flex;
flex: 1;
}
.app-row > .launch:hover,
.app-row > .kill:hover {
color: #000;
}
.app-row .icon {
height: 1em;
margin-right: 5px;
width: 1em;
}
.app-row .icon.-missing {
background-color: #eee;
color: #b6b6b6;
text-align: center;
}
.app-row .icon.-missing::after {
content: "?";
font-size: 0.7em;
vertical-align: middle;
}
.app-row.-selected {
background-color: #bcc6fa;
}
.app-loader > .opening {
margin-bottom: 10px;
}
.app-loader > .app-row {
color: #000;
justify-content: center;
}
.app-loader > .cancel {
background: transparent;
border: none;
color: #b6b6b6;
cursor: pointer;
margin-top: 10px;
width: 100%;
}
.app-list + .app-list {
border-top: 1px solid #b6b6b6;
margin-top: 1em;
padding-top: 1em;
}
.app-list > .header {
color: #b6b6b6;
font-size: 1em;
margin-bottom: 1em;
margin-top: 0;
}
.app-list > .loader {
color: #b6b6b6;
}
.app-list > .app-row {
cursor: pointer;
}

View File

@ -1,201 +0,0 @@
import * as React from "react"
import { Application, isExecutableApplication, isRunningApplication } from "../../common/api"
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<Application> = (props) => {
return (
<>
{props.icon ? (
<img className="icon" src={`data:image/png;base64,${props.icon}`}></img>
) : (
<div className="icon -missing"></div>
)}
<div className="name">{props.name}</div>
</>
)
}
export interface AppRowProps {
readonly app: Application
onKilled(app: Application): void
open(app: Application): void
}
/**
* A single application row. Can be killed if it's a running application.
*/
export const AppRow: React.FunctionComponent<AppRowProps> = (props) => {
const [killing, setKilling] = React.useState<boolean>(false)
const [error, setError] = React.useState<HttpError>()
function kill(): void {
if (isRunningApplication(props.app)) {
setKilling(true)
killSession(props.app)
.then(() => {
setKilling(false)
props.onKilled(props.app)
})
.catch((error) => {
setError(error)
setKilling(false)
})
}
}
return (
<div className="app-row">
<button className="launch" onClick={(): void => props.open(props.app)}>
<AppDetails {...props.app} />
</button>
{isRunningApplication(props.app) && !killing ? (
<button className="kill" onClick={(): void => kill()}>
{error ? error.message : killing ? "..." : "kill"}
</button>
) : (
undefined
)}
</div>
)
}
export interface AppListProps {
readonly header: string
readonly apps?: ReadonlyArray<Application>
open(app: Application): void
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<AppListProps> = (props) => {
return (
<div className="app-list">
<h2 className="header">{props.header}</h2>
{props.apps && props.apps.length > 0 ? (
props.apps.map((app, i) => <AppRow key={i} app={app} {...props} />)
) : props.apps ? (
<RequestError error={`No ${props.header.toLowerCase()} found`} />
) : (
<div className="loader">loading...</div>
)}
</div>
)
}
export interface ApplicationSection {
readonly apps?: ReadonlyArray<Application>
readonly header: string
}
export interface AppLoaderProps {
readonly app?: Application
setApp(app?: Application): void
getApps(): Promise<ReadonlyArray<ApplicationSection>>
}
/**
* Application sections/groups. Handles loading of the application
* sections, errors, opening applications, and killing applications.
*/
export const AppLoader: React.FunctionComponent<AppLoaderProps> = (props) => {
const [apps, setApps] = React.useState<ReadonlyArray<ApplicationSection>>()
const [error, setError] = React.useState<HttpError | Error>()
const refresh = (): void => {
props
.getApps()
.then(setApps)
.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((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) {
return (
<RequestError
error={error}
onCloseText={props.app ? "Go Back" : "Try Again"}
onClose={(): void => {
setError(undefined)
if (!props.app) {
refresh()
}
}}
/>
)
}
// If an app is currently loading, provide the option to cancel.
if (props.app && !props.app.loaded) {
return (
<div className="app-loader">
<div className="opening">Opening</div>
<div className="app-row">
<AppDetails {...props.app} />
</div>
<button
className="cancel"
onClick={(): void => {
props.setApp(undefined)
}}
>
Cancel
</button>
</div>
)
}
// Apps are currently loading.
if (!apps) {
return (
<div className="app-loader">
<div className="loader">loading...</div>
</div>
)
}
// Apps have loaded.
return (
<>
{apps.map((section, i) => (
<AppList key={i} open={open} onKilled={refresh} {...section} />
))}
</>
)
}

View File

@ -1,61 +0,0 @@
import * as React from "react"
export const Logo = (props: React.SVGProps<SVGSVGElement>): JSX.Element => (
<svg
className="logo"
version="1.1"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 172 117"
xmlSpace="preserve"
fill="currentcolor"
{...props}
>
<g>
<g>
<path
className="st0"
d="M164.8,50.9c-3.3,0-5.5-1.9-5.5-5.8V22.7c0-14.3-6-22.2-21.5-22.2h-7.2v15.1h2.2c6.1,0,9,3.3,9,9.2v19.8
c0,8.6,2.6,12.1,8.3,13.9c-5.7,1.7-8.3,5.3-8.3,13.9c0,4.9,0,9.8,0,14.7c0,4.1,0,8.1-1.1,12.2c-1.1,3.8-2.9,7.4-5.4,10.5
c-1.4,1.8-3,3.3-4.8,4.7v2h7.2c15.5,0,21.5-7.9,21.5-22.2V71.9c0-4,2.1-5.8,5.5-5.8h4.1V51h-4V50.9L164.8,50.9z"
/>
<path
className="st0"
d="M115.8,23.3H93.6c-0.5,0-0.9-0.4-0.9-0.9v-1.7c0-0.5,0.4-0.9,0.9-0.9h22.3c0.5,0,0.9,0.4,0.9,0.9v1.7
C116.8,22.9,116.3,23.3,115.8,23.3z"
/>
<path
className="st0"
d="M119.6,44.9h-16.2c-0.5,0-0.9-0.4-0.9-0.9v-1.7c0-0.5,0.4-0.9,0.9-0.9h16.2c0.5,0,0.9,0.4,0.9,0.9V44
C120.5,44.4,120.1,44.9,119.6,44.9z"
/>
<path
className="st0"
d="M126,34.1H93.6c-0.5,0-0.9-0.4-0.9-0.9v-1.7c0-0.5,0.4-0.9,0.9-0.9h32.3c0.5,0,0.9,0.4,0.9,0.9v1.7
C126.8,33.6,126.5,34.1,126,34.1z"
/>
<g>
<path
className="st0"
d="M67.9,28.2c2.2,0,4.4,0.2,6.5,0.7v-4.1c0-5.8,3-9.2,9-9.2h2.2V0.5h-7.2c-15.5,0-21.5,7.9-21.5,22.2v7.4
C60.4,28.9,64.1,28.2,67.9,28.2z"
/>
</g>
<path
className="st0"
d="M132.8,82.6c-1.6-12.7-11.4-23.3-24-25.7c-3.5-0.7-7-0.8-10.4-0.2c-0.1,0-0.1-0.1-0.2-0.1
c-5.5-11.5-17.3-19.1-30.1-19.1S43.6,44.9,38,56.4c-0.1,0-0.1,0.1-0.2,0.1c-3.6-0.4-7.2-0.2-10.8,0.7c-12.4,3-21.8,13.4-23.5,26
c-0.2,1.3-0.3,2.6-0.3,3.8c0,3.8,2.6,7.3,6.4,7.8c4.7,0.7,8.8-2.9,8.7-7.5c0-0.7,0-1.5,0.1-2.2c0.8-6.4,5.7-11.8,12.1-13.3
c2-0.5,4-0.6,5.9-0.3c6.1,0.8,12.1-2.3,14.7-7.7c1.9-4,4.9-7.5,8.9-9.4c4.4-2.1,9.4-2.4,14-0.8c4.8,1.7,8.4,5.3,10.6,9.8
c2.3,4.4,3.4,7.5,8.3,8.1c2,0.3,7.6,0.2,9.7,0.1c4.1,0,8.2,1.4,11.1,4.3c1.9,2,3.3,4.5,3.9,7.3c0.9,4.5-0.2,9-2.9,12.4
c-1.9,2.4-4.5,4.2-7.4,5c-1.4,0.4-2.8,0.5-4.2,0.5c-0.8,0-1.9,0-3.2,0c-4,0-12.5,0-18.9,0c-4.4,0-7.9-3.5-7.9-7.9V78.4V63.9
c0-1.2-1-2.2-2.2-2.2h-3.1c-6.1,0.1-11,6.9-11,14.1s0,26.3,0,26.3c0,7.8,6.3,14.1,14.1,14.1c0,0,34.7-0.1,35.2-0.1
c8-0.8,15.4-4.9,20.4-11.2C131.5,98.8,133.8,90.8,132.8,82.6z"
/>
</g>
</g>
</svg>
)

View File

@ -1,152 +0,0 @@
.modal-bar {
box-sizing: border-box;
display: flex;
justify-content: center;
left: 0;
padding: 20px;
position: fixed;
pointer-events: none;
top: 0;
width: 100%;
z-index: 30;
}
.animate > .modal-bar {
transform: translateY(-100%);
transition: transform 200ms;
}
.animate.-show > .modal-bar {
transform: translateY(0);
}
.modal-bar > .bar {
background-color: #fcfcfc;
border-radius: 5px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
box-sizing: border-box;
color: #101010;
display: flex;
font-size: 0.8em;
max-width: 400px;
padding: 20px;
pointer-events: initial;
position: relative;
}
.modal-bar > .bar > .content {
display: flex;
flex-direction: column;
flex: 1;
justify-content: center;
padding-right: 20px;
}
.modal-bar > .bar > .open {
display: flex;
flex-direction: column;
justify-content: center;
}
.modal-bar > .bar > .close {
background-color: transparent;
border: none;
color: #b6b6b6;
cursor: pointer;
position: absolute;
right: 1px;
top: 1px;
}
.modal-bar > .bar > .open > .button {
background-color: transparent;
border-radius: 5px;
border: 1px solid #101010;
color: #101010;
cursor: pointer;
padding: 1em;
}
.modal-bar > .bar > .open > .button:hover {
background-color: #bcc6fa;
}
.modal-container {
align-items: center;
background: rgba(0, 0, 0, 0.1);
box-sizing: border-box;
display: flex;
height: 100%;
justify-content: center;
left: 0;
padding: 20px;
position: fixed;
top: 0;
width: 100%;
z-index: 9999999;
}
.modal-container > .modal {
background: #fcfcfc;
border-radius: 10px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: row;
height: 100%;
max-height: 400px;
max-width: 664px;
padding: 20px 0;
position: relative;
width: 100%;
}
.sidebar-nav {
display: flex;
flex-direction: column;
justify-content: space-between;
min-width: 145px;
}
.sidebar-nav > .links {
display: flex;
flex-direction: column;
}
.sidebar-nav > .links > .link {
background-color: transparent;
border: none;
cursor: pointer;
color: rgba(0, 0, 0, 0.37);
font-size: 1.4em;
height: 31px;
margin-bottom: 20px;
padding: 0 35px;
text-decoration: none;
transition: 150ms color ease, 150ms height ease, 150ms margin-bottom ease;
}
.sidebar-nav > .footer > .close {
background: transparent;
border: none;
color: #b6b6b6;
cursor: pointer;
width: 100%;
}
.sidebar-nav > .links > .link[aria-current="page"],
.sidebar-nav > .links > .link:hover,
.sidebar-nav > .footer > .close:hover {
color: #000;
}
.modal-container > .modal > .content {
display: flex;
flex: 1;
flex-direction: column;
overflow: auto;
padding: 0 20px;
}
.modal-container > .modal > .sidebar-nav {
border-right: 1.5px solid rgba(0, 0, 0, 0.37);
}

View File

@ -1,190 +0,0 @@
import { logger } from "@coder/logger"
import * as React from "react"
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"
export interface ModalProps {
app?: Application
authed: boolean
error?: HttpError | Error | string
setApp(app?: Application): void
setError(error?: HttpError | Error | string): void
}
enum Section {
Browse,
Home,
Open,
Recent,
}
export const Modal: React.FunctionComponent<ModalProps> = (props) => {
const [showModal, setShowModal] = React.useState<boolean>(false)
const [showBar, setShowBar] = React.useState<boolean>(false) // TEMP: Will be true.
const [section, setSection] = React.useState<Section>(Section.Home)
const setApp = (app: Application): void => {
setShowModal(false)
props.setApp(app)
}
React.useEffect(() => {
// Show the bar when hovering around the top area for a while.
let timeout: NodeJS.Timeout | undefined
const hover = (clientY: number): void => {
if (clientY > 30 && timeout) {
clearTimeout(timeout)
timeout = undefined
} else if (clientY <= 30 && !timeout) {
// TEMP: No bar for now.
// timeout = setTimeout(() => setShowBar(true), 1000)
}
}
const iframe =
props.app && !isExecutableApplication(props.app) && (document.getElementById("iframe") as HTMLIFrameElement)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const postIframeMessage = (message: any): void => {
if (iframe && iframe.contentWindow) {
iframe.contentWindow.postMessage(message, window.location.origin)
} else {
logger.warn("Tried to post message to missing iframe")
}
}
const onHover = (event: MouseEvent | MessageEvent): void => {
hover((event as MessageEvent).data ? (event as MessageEvent).data.clientY : (event as MouseEvent).clientY)
}
const onIframeLoaded = (): void => {
if (props.app) {
setApp({ ...props.app, loaded: true })
}
}
// No need to track the mouse if we don't have a hidden bar.
const hasHiddenBar = !props.error && !showModal && props.app && !showBar
if (props.app && !isExecutableApplication(props.app)) {
// Once the iframe reports it has loaded, tell it to bind mousemove and
// start listening for that instead.
if (!props.app.loaded) {
window.addEventListener("message", onIframeLoaded)
} else if (hasHiddenBar) {
postIframeMessage({ bind: "mousemove", prop: "clientY" })
window.removeEventListener("message", onIframeLoaded)
window.addEventListener("message", onHover)
}
} else if (hasHiddenBar) {
document.addEventListener("mousemove", onHover)
}
return (): void => {
document.removeEventListener("mousemove", onHover)
window.removeEventListener("message", onHover)
window.removeEventListener("message", onIframeLoaded)
if (props.app && !isExecutableApplication(props.app)) {
postIframeMessage({ unbind: "mousemove" })
}
if (timeout) {
clearTimeout(timeout)
}
}
}, [showBar, props.error, showModal, props.app])
const content = (): React.ReactElement => {
if (!props.authed) {
return <Login setApp={setApp} />
}
switch (section) {
case Section.Recent:
return <Recent app={props.app} setApp={setApp} />
case Section.Home:
return <Home app={props.app} />
case Section.Browse:
return <Browse />
case Section.Open:
return <Open app={props.app} setApp={setApp} />
default:
return <Missing />
}
}
return props.error || showModal || !props.app || !props.app.loaded ? (
<div className="modal-container">
<div className="modal">
{props.authed && (!props.app || props.app.loaded) ? (
<aside className="sidebar-nav">
<nav className="links">
{props.authed ? (
<>
<button className="link" onClick={(): void => setSection(Section.Recent)}>
Recent
</button>
<button className="link" onClick={(): void => setSection(Section.Open)}>
Open
</button>
<button className="link" onClick={(): void => setSection(Section.Browse)}>
Browse
</button>
</>
) : (
undefined
)}
</nav>
<div className="footer">
{props.app && props.app.loaded && !props.error ? (
<button className="close" onClick={(): void => setShowModal(false)}>
Close
</button>
) : (
undefined
)}
</div>
</aside>
) : (
undefined
)}
{props.error ? (
<RequestError
error={props.error}
onClose={(): void => {
props.setApp(undefined)
props.setError(undefined)
}}
/>
) : (
<div className="content">{content()}</div>
)}
</div>
</div>
) : (
<Animate show={showBar} delay={200}>
<div className="modal-bar">
<div className="bar">
<div className="content">
<div className="help">
Hover at the top {/*or press <strong>Ctrl+Shift+G</strong>*/} to display this menu.
</div>
</div>
<div className="open">
<button className="button" onClick={(): void => setShowModal(true)}>
Open Modal
</button>
</div>
<button className="close" onClick={(): void => setShowBar(false)}>
x
</button>
</div>
</div>
</Animate>
)
}

View File

@ -1,14 +0,0 @@
import * as React from "react"
import * as ReactDOM from "react-dom"
import App from "./app"
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(<App options={getOptions()} />, document.getElementById("root"))

24
src/browser/pages/app.css Normal file
View File

@ -0,0 +1,24 @@
/* NOTE: Disable scrollbars since an oversized element creates them. */
.app-input {
height: 100%;
left: 0;
outline: none;
position: fixed;
scrollbar-width: none;
top: 0;
width: 100%;
z-index: 20;
}
.app-input::-webkit-scrollbar {
display: none;
}
.app-render {
height: 100%;
left: 0;
position: fixed;
top: 0;
width: 100%;
z-index: 10;
}

View File

@ -3,17 +3,15 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
<meta http-equiv="Content-Security-Policy" content="font-src 'self' fonts.gstatic.com; connect-src 'self'; default-src ws: wss: 'self'; style-src 'self' fonts.googleapis.com; script-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:;"> <meta http-equiv="Content-Security-Policy" content="style-src 'self'; manifest-src 'self'; img-src 'self' data:;">
<title>code-server</title> <title>code-server — {{APP_NAME}}</title>
<link rel="icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" /> <link rel="icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
<link rel="manifest" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/manifest.json" crossorigin="use-credentials"> <link rel="manifest" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/manifest.json" crossorigin="use-credentials">
<link rel="apple-touch-icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/code-server.png" /> <link rel="apple-touch-icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/code-server.png" />
<link href="https://fonts.googleapis.com/css?family=IBM+Plex+Sans&display=swap" rel="stylesheet" /> <link href="{{BASE}}/static-{{COMMIT}}/dist/app.css" rel="stylesheet">
<link href="{{BASE}}/static-{{COMMIT}}/dist/index.css" rel="stylesheet">
<meta id="coder-options" data-settings="{{OPTIONS}}"> <meta id="coder-options" data-settings="{{OPTIONS}}">
</head> </head>
<body> <body>
<div id="root">{{COMPONENT}}</div> <script src="{{BASE}}/static-{{COMMIT}}/dist/app.js"></script>
<script src="{{BASE}}/static-{{COMMIT}}/dist/index.js"></script>
</body> </body>
</html> </html>

14
src/browser/pages/app.ts Normal file
View File

@ -0,0 +1,14 @@
import { getOptions } from "../../common/util"
import "./app.css"
import "./error.css"
import "./global.css"
import "./home.css"
import "./login.css"
const options = getOptions()
const parts = window.location.pathname.replace(/^\//g, "").split("/")
parts[parts.length - 1] = options.base
const url = new URL(window.location.origin + "/" + parts.join("/"))
console.log(url)

View File

@ -1,33 +0,0 @@
import * as React from "react"
import { FilesResponse } from "../../common/api"
import { HttpError } from "../../common/http"
import { getFiles } from "../api"
import { RequestError } from "../components/error"
/**
* File browser.
*/
export const Browse: React.FunctionComponent = (props) => {
const [response, setResponse] = React.useState<FilesResponse>()
const [error, setError] = React.useState<HttpError>()
React.useEffect(() => {
getFiles()
.then(setResponse)
.catch(setError)
}, [props])
return (
<>
{error || (response && response.files.length === 0) ? (
<RequestError error={error || "Empty directory"} />
) : (
<ul>
{((response && response.files) || []).map((f, i) => (
<li key={i}>{f.name}</li>
))}
</ul>
)}
</>
)
}

View File

@ -0,0 +1,20 @@
.error-display {
box-sizing: border-box;
color: #fcfcfc;
padding: 20px;
text-align: center;
}
.error-display > .links {
margin-top: 1rem;
}
.error-display > .links > .link {
color: #b6b6b6;
text-decoration: none;
}
.error-display > .links > .link:hover {
color: #fcfcfc;
text-decoration: underline;
}

View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
<meta http-equiv="Content-Security-Policy" content="style-src 'self'; manifest-src 'self'; img-src 'self' data:;">
<title>code-server {{ERROR_TITLE}}</title>
<link rel="icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
<link rel="manifest" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/manifest.json" crossorigin="use-credentials">
<link rel="apple-touch-icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/code-server.png" />
<link href="{{BASE}}/static-{{COMMIT}}/dist/app.css" rel="stylesheet">
</head>
<body>
<div class="center-container">
<div class="error-display">
<h2 class="header">{{ERROR_HEADER}}</h2>
<div class="body">
{{ERROR_BODY}}
</div>
<div class="links">
<a class="link" href="{{BASE}}">go home</a
</div>
</div>
</div>
</body>
</html>

View File

@ -1,20 +1,16 @@
html, html,
body, body,
#root, #root,
iframe { .center-container {
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
iframe {
border: none;
}
body { body {
background: #272727; background: #272727;
color: #f4f4f4; color: #f4f4f4;
margin: 0; margin: 0;
font-family: 'IBM Plex Sans', sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
overflow: hidden; overflow: hidden;
} }
@ -22,20 +18,10 @@ button {
font-family: inherit; font-family: inherit;
} }
.coder-splash { .center-container {
align-items: center; align-items: center;
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
height: 100%;
justify-content: center; justify-content: center;
left: 0; padding: 20px;
position: fixed;
top: 0;
width: 100%;
z-index: 5;
}
.coder-splash > .logo {
color: rgba(255, 255, 255, 0.03);
width: 100%;
} }

View File

@ -1,8 +1,70 @@
.orientation-guide { .app-lists {
align-items: center; max-width: 400px;
width: 100%;
}
.app-list > .header {
margin: 1rem 0;
}
.app-list > .none {
color: #b6b6b6; color: #b6b6b6;
}
.app-list + .app-list {
border-top: 1px solid #666;
margin-top: 1rem;
}
.app-row {
display: flex;
}
.app-row > .open {
color: #b6b6b6;
cursor: pointer;
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column; text-decoration: none;
justify-content: center; }
.app-row > .open:hover {
color: #fafafa;
}
.app-row > .open > .icon {
height: 1rem;
margin-right: 5px;
width: 1rem;
}
.app-row > .open > .icon.-missing {
background-color: #eee;
color: #b6b6b6;
text-align: center;
}
.app-row > .open > .icon.-missing::after {
content: "?";
font-size: 0.7rem;
vertical-align: middle;
}
.kill-form {
display: inline-block;
}
.kill-form > .kill {
background-color: transparent;
border: none;
color: #b6b6b6;
cursor: pointer;
font-size: 1rem;
line-height: 1rem;
margin: 0;
padding: 0;
}
.kill-form > .kill:hover {
color: #fafafa;
} }

View File

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
<meta http-equiv="Content-Security-Policy" content="style-src 'self'; manifest-src 'self'; img-src 'self' data:;">
<title>code-server</title>
<link rel="icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
<link rel="manifest" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/manifest.json" crossorigin="use-credentials">
<link rel="apple-touch-icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/code-server.png" />
<link href="{{BASE}}/static-{{COMMIT}}/dist/app.css" rel="stylesheet">
<meta id="coder-options" data-settings="{{OPTIONS}}">
</head>
<body>
<div class="center-container">
<div class="app-lists">
<div class="app-list">
<h2 class="header">Running Applications</h2>
{{APP_LIST:RUNNING}}
</div>
<div class="app-list">
<h2 class="header">Editors</h2>
{{APP_LIST:EDITORS}}
</div>
<div class="app-list">
<h2 class="header">Other</h2>
{{APP_LIST:OTHER}}
</div>
</div>
</div>
</body>
</html>

View File

@ -1,22 +0,0 @@
import * as React from "react"
import { Application } from "../../common/api"
import { authenticate, setAuthed } from "../api"
export interface HomeProps {
app?: Application
}
export const Home: React.FunctionComponent<HomeProps> = (props) => {
React.useEffect(() => {
authenticate()
.then(() => setAuthed(true))
.catch(() => undefined)
}, [])
return (
<div className="orientation-guide">
<div className="welcome">Welcome to code-server.</div>
{props.app && !props.app.loaded ? <div className="loader">loading...</div> : undefined}
</div>
)
}

View File

@ -1,10 +1,25 @@
.login-form { .login-form {
align-items: center; align-items: center;
background: #fcfcfc;
border-radius: 10px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
color: rgba(0, 0, 0, 0.37); color: rgba(0, 0, 0, 0.37);
display: flex; display: flex;
flex: 1;
flex-direction: column; flex-direction: column;
flex: 1;
height: 100%;
justify-content: center; justify-content: center;
max-height: 400px;
max-width: 664px;
padding: 20px;
position: relative;
width: 100%;
}
.login-form > .header {
align-items: center;
color: #b6b6b6;
margin-bottom: 1rem;
} }
.login-form > .field { .login-form > .field {
@ -13,18 +28,19 @@
width: 100%; width: 100%;
} }
.login-form > .field-error { .login-form > .error {
margin-top: 1em; color: red;
margin-top: 1rem;
} }
.login-form > .field > .password { .login-form > .field > .password {
border: 1px solid #b6b6b6; border: 1px solid #b6b6b6;
box-sizing: border-box; box-sizing: border-box;
padding: 1em; padding: 1rem;
flex: 1; flex: 1;
} }
.login-form > .field > .user { .login-form > .user {
display: none; display: none;
} }
@ -33,11 +49,5 @@
border: 1px solid #b6b6b6; border: 1px solid #b6b6b6;
box-sizing: border-box; box-sizing: border-box;
margin-left: -1px; margin-left: -1px;
padding: 1em 2em; padding: 1rem 2rem;
}
.login-header {
align-items: center;
color: #b6b6b6;
margin-bottom: 1em;
} }

View File

@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
<meta http-equiv="Content-Security-Policy" content="style-src 'self'; script-src 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:;">
<title>code-server login</title>
<link rel="icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
<link rel="manifest" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/manifest.json" crossorigin="use-credentials">
<link rel="apple-touch-icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/code-server.png" />
<link href="{{BASE}}/static-{{COMMIT}}/dist/app.css" rel="stylesheet">
</head>
<body>
<div class="center-container">
<form class="login-form" method="post">
<div class="header">
<div class="main">Welcome to code-server</div>
<div class="sub">Please log in below</div>
</div>
<input class="user" type="text" autocomplete="username" />
<input id="base" type="hidden" name="base" value="/" />
<div class="field">
<!-- The onfocus code places the cursor at the end of the value. -->
<input
required autofocus
value="{{VALUE}}"
onfocus="const value=this.value;this.value='';this.value=value;"
class="password"
type="password"
placeholder="password"
name="password"
autocomplete="current-password"
/>
<button class="submit" type="submit">
Log In
</button>
</div>
{{ERROR}}
</form>
</div>
</body>
<script>
const parts = window.location.pathname.replace(/^\//g, "").split("/")
parts[parts.length - 1] = "{{BASE}}"
const url = new URL(window.location.origin + "/" + parts.join("/"))
document.getElementById("base").value = url.pathname
</script>
</html>

View File

@ -1,59 +0,0 @@
import * as React from "react"
import { Application } from "../../common/api"
import { HttpError } from "../../common/http"
import { authenticate, setAuthed } from "../api"
import { FieldError } from "../components/error"
export interface LoginProps {
setApp(app: Application): void
}
/**
* Login page. Will redirect on success.
*/
export const Login: React.FunctionComponent<LoginProps> = (props) => {
const [password, setPassword] = React.useState<string>("")
const [error, setError] = React.useState<HttpError>()
async function handleSubmit(event: React.FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault()
authenticate({ password })
.then((response) => {
if (response.app) {
props.setApp(response.app)
}
setAuthed(true)
})
.catch(setError)
}
React.useEffect(() => {
authenticate()
.then(() => setAuthed(true))
.catch(() => undefined)
}, [])
return (
<form className="login-form" onSubmit={handleSubmit}>
<div className="login-header">
<div className="main">Welcome to code-server</div>
<div className="sub">Please log in below</div>
</div>
<div className="field">
<input className="user" type="text" autoComplete="username" />
<input
autoFocus
className="password"
type="password"
placeholder="password"
autoComplete="current-password"
onChange={(event: React.ChangeEvent<HTMLInputElement>): void => setPassword(event.target.value)}
/>
<button className="submit" type="submit">
Log In
</button>
</div>
{error ? <FieldError error={error} /> : undefined}
</form>
)
}

View File

@ -1,8 +0,0 @@
.missing-message {
align-items: center;
color: #b6b6b6;
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
}

View File

@ -1,9 +0,0 @@
import * as React from "react"
export const Missing: React.FunctionComponent = () => {
return (
<div className="missing-message">
<div className="message">404</div>
</div>
)
}

View File

@ -1,29 +0,0 @@
import * as React from "react"
import { Application } from "../../common/api"
import { getApplications } from "../api"
import { ApplicationSection, AppLoader } from "../components/list"
export interface OpenProps {
app?: Application
setApp(app: Application): void
}
/**
* Display recently used applications.
*/
export const Open: React.FunctionComponent<OpenProps> = (props) => {
return (
<AppLoader
getApps={async (): Promise<ReadonlyArray<ApplicationSection>> => {
const response = await getApplications()
return [
{
header: "Applications",
apps: response && response.applications,
},
]
}}
{...props}
/>
)
}

View File

@ -1,33 +0,0 @@
import * as React from "react"
import { Application } from "../../common/api"
import { getRecent } from "../api"
import { ApplicationSection, AppLoader } from "../components/list"
export interface RecentProps {
app?: Application
setApp(app: Application): void
}
/**
* Display recently used applications.
*/
export const Recent: React.FunctionComponent<RecentProps> = (props) => {
return (
<AppLoader
getApps={async (): Promise<ReadonlyArray<ApplicationSection>> => {
const response = await getRecent()
return [
{
header: "Running Applications",
apps: response && response.running,
},
{
header: "Recent Applications",
apps: response && response.recent,
},
]
}}
{...props}
/>
)
}

View File

@ -4,6 +4,8 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="font-src 'self'; connect-src 'self' https:; default-src ws: wss: 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; manifest-src 'self'; img-src 'self' data:;">
<!-- Disable pinch zooming --> <!-- Disable pinch zooming -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
@ -12,8 +14,6 @@
<!-- Workarounds/Hacks (remote user data uri) --> <!-- Workarounds/Hacks (remote user data uri) -->
<meta id="vscode-remote-user-data-uri" data-settings="{{REMOTE_USER_DATA_URI}}"> <meta id="vscode-remote-user-data-uri" data-settings="{{REMOTE_USER_DATA_URI}}">
<!-- NOTE@coder: Added the commit for use in caching, the product for the
extensions gallery URL, and nls for language support. -->
<meta id="vscode-remote-commit" data-settings="{{COMMIT}}"> <meta id="vscode-remote-commit" data-settings="{{COMMIT}}">
<meta id="vscode-remote-product-configuration" data-settings="{{PRODUCT_CONFIGURATION}}"> <meta id="vscode-remote-product-configuration" data-settings="{{PRODUCT_CONFIGURATION}}">
<meta id="vscode-remote-nls-configuration" data-settings="{{NLS_CONFIGURATION}}"> <meta id="vscode-remote-nls-configuration" data-settings="{{NLS_CONFIGURATION}}">
@ -21,12 +21,16 @@
<!-- Workbench Icon/Manifest/CSS --> <!-- Workbench Icon/Manifest/CSS -->
<link rel="icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" /> <link rel="icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
<link rel="manifest" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/manifest.json" crossorigin="use-credentials"> <link rel="manifest" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/manifest.json" crossorigin="use-credentials">
<!-- PROD_ONLY
<link data-name="vs/workbench/workbench.web.api" rel="stylesheet" href="{{VS_BASE}}/static-{{COMMIT}}/out/vs/workbench/workbench.web.api.css"> <link data-name="vs/workbench/workbench.web.api" rel="stylesheet" href="{{VS_BASE}}/static-{{COMMIT}}/out/vs/workbench/workbench.web.api.css">
END_PROD_ONLY -->
<link rel="apple-touch-icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/code-server.png" /> <link rel="apple-touch-icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/code-server.png" />
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<!-- Prefetch to avoid waterfall --> <!-- Prefetch to avoid waterfall -->
<link rel="prefetch" href="{{BASE}}/static-{{COMMIT}}/node_modules/semver-umd/lib/semver-umd.js"> <!-- PROD_ONLY
<link rel="prefetch" href="{{VS_BASE}}/static-{{COMMIT}}/node_modules/semver-umd/lib/semver-umd.js">
END_PROD_ONLY -->
</head> </head>
<body aria-label=""> <body aria-label="">
@ -78,12 +82,10 @@
}; };
</script> </script>
<script src="{{VS_BASE}}/static-{{COMMIT}}/out/vs/loader.js"></script> <script src="{{VS_BASE}}/static-{{COMMIT}}/out/vs/loader.js"></script>
<!-- PROD_ONLY
<script src="{{VS_BASE}}/static-{{COMMIT}}/out/vs/workbench/workbench.web.api.nls.js"></script> <script src="{{VS_BASE}}/static-{{COMMIT}}/out/vs/workbench/workbench.web.api.nls.js"></script>
<script src="{{VS_BASE}}/static-{{COMMIT}}/out/vs/workbench/workbench.web.api.js"></script> <script src="{{VS_BASE}}/static-{{COMMIT}}/out/vs/workbench/workbench.web.api.js"></script>
<!-- TODO@coder: This errors with multiple anonymous define calls (one is END_PROD_ONLY -->
workbench.js and one is semver-umd.js). For now use the same method found in
workbench-dev.html. Appears related to the timing of the script load events. -->
<!-- <script src="{{VS_BASE}}/static-{{COMMIT}}/out/vs/workbench/workbench.js"></script> -->
<script> <script>
require(['vs/code/browser/workbench/workbench'], function() {}); require(['vs/code/browser/workbench/workbench'], function() {});
</script> </script>

View File

@ -1,13 +1,15 @@
export interface Application { export interface Application {
readonly categories?: string[]
readonly comment?: string readonly comment?: string
readonly directory?: string readonly directory?: string
readonly embedPath?: string
readonly exec?: string readonly exec?: string
readonly genericName?: string
readonly icon?: string readonly icon?: string
readonly loaded?: boolean readonly installed?: boolean
readonly name: string readonly name: string
readonly path: string readonly path?: string
readonly sessionId?: string readonly sessionId?: string
readonly version?: string
} }
export interface ApplicationsResponse { export interface ApplicationsResponse {
@ -22,52 +24,17 @@ export enum SessionError {
Unknown, Unknown,
} }
export interface LoginRequest { export interface SessionResponse {
basePath: string
password: string
}
export interface LoginResponse {
/** /**
* An application to load immediately after logging in. * Whether the session was created or an existing one was returned.
*/ */
app?: Application created: boolean
success: boolean
}
export interface CreateSessionResponse {
sessionId: string sessionId: string
} }
export interface ExecutableApplication extends Application {
exec: string
}
export const isExecutableApplication = (app: Application): app is ExecutableApplication => {
return !!(app as ExecutableApplication).exec
}
export interface RunningApplication extends ExecutableApplication {
sessionId: string
}
export const isRunningApplication = (app: Application): app is RunningApplication => {
return !!(app as RunningApplication).sessionId
}
export interface RecentResponse { export interface RecentResponse {
readonly recent: ReadonlyArray<Application> readonly recent: ReadonlyArray<Application>
readonly running: ReadonlyArray<RunningApplication> readonly running: ReadonlyArray<Application>
}
export interface FileEntry {
readonly type: "file" | "directory"
readonly name: string
readonly size: number
}
export interface FilesResponse {
files: FileEntry[]
} }
export interface HealthRequest { export interface HealthRequest {

View File

@ -17,8 +17,8 @@ export class HttpError extends Error {
export enum ApiEndpoint { export enum ApiEndpoint {
applications = "/applications", applications = "/applications",
files = "/files",
login = "/login",
recent = "/recent", recent = "/recent",
run = "/run",
session = "/session", session = "/session",
status = "/status",
} }

View File

@ -1,11 +1,9 @@
import { logger } from "@coder/logger" import { logger } from "@coder/logger"
import { Application } from "../common/api"
export interface Options { export interface Options {
app?: Application base: string
authed: boolean
basePath: string
logLevel: number logLevel: number
sessionId: string
} }
/** /**

View File

@ -1,164 +0,0 @@
import { field, logger } from "@coder/logger"
import * as http from "http"
import * as net from "net"
import * as ws from "ws"
import {
Application,
ApplicationsResponse,
ClientMessage,
FilesResponse,
LoginRequest,
LoginResponse,
ServerMessage,
} from "../../common/api"
import { ApiEndpoint, HttpCode } from "../../common/http"
import { normalize } from "../../common/util"
import { HttpProvider, HttpProviderOptions, HttpResponse, HttpServer, Route } from "../http"
import { hash } from "../util"
export const Vscode: Application = {
name: "VS Code",
path: "/",
embedPath: "./vscode-embed",
}
/**
* API HTTP provider.
*/
export class ApiHttpProvider extends HttpProvider {
private readonly ws = new ws.Server({ noServer: true })
public constructor(options: HttpProviderOptions, private readonly server: HttpServer) {
super(options)
}
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse | undefined> {
switch (route.base) {
case ApiEndpoint.login:
if (request.method === "POST") {
return this.login(request)
}
break
}
if (!this.authenticated(request)) {
return { code: HttpCode.Unauthorized }
}
switch (route.base) {
case ApiEndpoint.applications:
return this.applications()
case ApiEndpoint.files:
return this.files()
}
return undefined
}
public async handleWebSocket(
_route: Route,
request: http.IncomingMessage,
socket: net.Socket,
head: Buffer
): Promise<true> {
if (!this.authenticated(request)) {
throw new Error("not authenticated")
}
await new Promise<ws>((resolve) => {
this.ws.handleUpgrade(request, socket, head, (ws) => {
const send = (event: ServerMessage): void => {
ws.send(JSON.stringify(event))
}
ws.on("message", (data) => {
logger.trace("got message", field("message", data))
try {
const message: ClientMessage = JSON.parse(data.toString())
this.getMessageResponse(message.event).then(send)
} catch (error) {
logger.error(error.message, field("message", data))
}
})
resolve()
})
})
return true
}
private async getMessageResponse(event: "health"): Promise<ServerMessage> {
switch (event) {
case "health":
return { event, connections: await this.server.getConnections() }
default:
throw new Error("unexpected message")
}
}
/**
* Return OK and a cookie if the user is authenticated otherwise return
* unauthorized.
*/
private async login(request: http.IncomingMessage): Promise<HttpResponse<LoginResponse>> {
// Already authenticated via cookies?
const providedPassword = this.authenticated(request)
if (providedPassword) {
return { code: HttpCode.Ok }
}
const data = await this.getData(request)
const payload: LoginRequest = data ? JSON.parse(data) : {}
const password = this.authenticated(request, {
key: typeof payload.password === "string" ? [hash(payload.password)] : undefined,
})
if (password) {
return {
content: {
success: true,
// TEMP: Auto-load VS Code.
app: Vscode,
},
cookie:
typeof password === "string"
? {
key: "key",
value: password,
path: normalize(payload.basePath),
}
: undefined,
}
}
// Only log if it was an actual login attempt.
if (payload && payload.password) {
console.error(
"Failed login attempt",
JSON.stringify({
xForwardedFor: request.headers["x-forwarded-for"],
remoteAddress: request.connection.remoteAddress,
userAgent: request.headers["user-agent"],
timestamp: Math.floor(new Date().getTime() / 1000),
})
)
}
return { code: HttpCode.Unauthorized }
}
/**
* Return files at the requested directory.
*/
private async files(): Promise<HttpResponse<FilesResponse>> {
return {
content: {
files: [],
},
}
}
/**
* Return available applications.
*/
private async applications(): Promise<HttpResponse<ApplicationsResponse>> {
return {
content: {
applications: [Vscode],
},
}
}
}

304
src/node/app/api.ts Normal file
View File

@ -0,0 +1,304 @@
import { field, logger } from "@coder/logger"
import * as cp from "child_process"
import * as http from "http"
import * as net from "net"
import * as WebSocket from "ws"
import {
Application,
ApplicationsResponse,
ClientMessage,
RecentResponse,
ServerMessage,
SessionError,
SessionResponse,
} from "../../common/api"
import { ApiEndpoint, HttpCode } from "../../common/http"
import { normalize } from "../../common/util"
import { HttpProvider, HttpProviderOptions, HttpResponse, HttpServer, Route } from "../http"
import { findApplications, findWhitelistedApplications } from "./bin"
interface ServerSession {
process?: cp.ChildProcess
readonly app: Application
}
/**
* API HTTP provider.
*/
export class ApiHttpProvider extends HttpProvider {
private readonly ws = new WebSocket.Server({ noServer: true })
private readonly sessions = new Map<string, ServerSession>()
public constructor(options: HttpProviderOptions, private readonly server: HttpServer) {
super(options)
}
public dispose(): void {
this.sessions.forEach((s) => {
if (s.process) {
s.process.kill()
}
})
}
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse | undefined> {
if (!this.authenticated(request)) {
return { code: HttpCode.Unauthorized }
}
switch (route.base) {
case ApiEndpoint.applications:
this.ensureMethod(request)
return {
content: {
applications: await this.applications(),
},
} as HttpResponse<ApplicationsResponse>
case ApiEndpoint.session:
return this.session(request)
case ApiEndpoint.recent:
this.ensureMethod(request)
return {
content: await this.recent(),
} as HttpResponse<RecentResponse>
}
return undefined
}
public async handleWebSocket(
route: Route,
request: http.IncomingMessage,
socket: net.Socket,
head: Buffer
): Promise<true | undefined> {
if (!this.authenticated(request)) {
throw new Error("not authenticated")
}
switch (route.base) {
case ApiEndpoint.status:
return this.handleStatusSocket(request, socket, head)
case ApiEndpoint.run:
return this.handleRunSocket(route, request, socket, head)
}
return undefined
}
private async handleStatusSocket(request: http.IncomingMessage, socket: net.Socket, head: Buffer): Promise<true> {
const getMessageResponse = async (event: "health"): Promise<ServerMessage> => {
switch (event) {
case "health":
return { event, connections: await this.server.getConnections() }
default:
throw new Error("unexpected message")
}
}
await new Promise<WebSocket>((resolve) => {
this.ws.handleUpgrade(request, socket, head, (ws) => {
const send = (event: ServerMessage): void => {
ws.send(JSON.stringify(event))
}
ws.on("message", (data) => {
logger.trace("got message", field("message", data))
try {
const message: ClientMessage = JSON.parse(data.toString())
getMessageResponse(message.event).then(send)
} catch (error) {
logger.error(error.message, field("message", data))
}
})
resolve()
})
})
return true
}
/**
* A socket that connects to a session.
*/
private async handleRunSocket(
route: Route,
request: http.IncomingMessage,
socket: net.Socket,
head: Buffer
): Promise<true> {
const sessionId = route.requestPath.replace(/^\//, "")
logger.debug("connecting session", field("sessionId", sessionId))
const ws = await new Promise<WebSocket>((resolve, reject) => {
this.ws.handleUpgrade(request, socket, head, (socket) => {
socket.binaryType = "arraybuffer"
const session = this.sessions.get(sessionId)
if (!session) {
socket.close(SessionError.NotFound)
return reject(new Error("session not found"))
}
resolve(socket as WebSocket)
socket.on("error", (error) => {
socket.close(SessionError.FailedToStart)
logger.error("got error while connecting socket", field("error", error))
reject(error)
})
})
})
// Send ready message.
ws.send(
Buffer.from(
JSON.stringify({
protocol: "TODO",
})
)
)
return true
}
/**
* Return whitelisted applications.
*/
public async applications(): Promise<ReadonlyArray<Application>> {
return findWhitelistedApplications()
}
/**
* Return installed applications.
*/
public async installedApplications(): Promise<ReadonlyArray<Application>> {
return findApplications()
}
/**
* Get a running application.
*/
public getRunningApplication(sessionIdOrPath?: string): Application | undefined {
if (!sessionIdOrPath) {
return undefined
}
const sessionId = sessionIdOrPath.replace(/\//g, "")
let session = this.sessions.get(sessionId)
if (session) {
logger.debug("found application by session id", field("id", sessionId))
return session.app
}
const base = normalize("/" + sessionIdOrPath)
session = Array.from(this.sessions.values()).find((s) => s.app.path === base)
if (session) {
logger.debug("found application by path", field("path", base))
return session.app
}
logger.debug("no application found matching route", field("route", sessionIdOrPath))
return undefined
}
/**
* Handle /session endpoint.
*/
private async session(request: http.IncomingMessage): Promise<HttpResponse | undefined> {
this.ensureMethod(request, ["DELETE", "POST"])
const data = await this.getData(request)
if (!data) {
return undefined
}
switch (request.method) {
case "DELETE":
return this.deleteSession(JSON.parse(data).sessionId)
case "POST": {
// Prevent spawning the same app multiple times.
const parsed: Application = JSON.parse(data)
const app = this.getRunningApplication(parsed.sessionId || parsed.path)
if (app) {
return {
content: {
created: false,
sessionId: app.sessionId,
},
} as HttpResponse<SessionResponse>
}
return {
content: {
created: true,
sessionId: await this.createSession(parsed),
},
} as HttpResponse<SessionResponse>
}
}
return undefined
}
/**
* Kill a session identified by `app.sessionId`.
*/
public deleteSession(sessionId: string): HttpResponse {
logger.debug("deleting session", field("sessionId", sessionId))
const session = this.sessions.get(sessionId)
if (!session) {
throw new Error("session does not exist")
}
if (session.process) {
session.process.kill()
}
this.sessions.delete(sessionId)
return { code: HttpCode.Ok }
}
/**
* Create a new session and return the session ID.
*/
public async createSession(app: Application): Promise<string> {
const sessionId = Math.floor(Math.random() * 10000).toString()
if (this.sessions.has(sessionId)) {
throw new Error("conflicting session id")
}
if (!app.exec) {
throw new Error("cannot execute application with no `exec`")
}
const appSession: ServerSession = {
app: {
...app,
sessionId,
},
}
this.sessions.set(sessionId, appSession)
try {
throw new Error("TODO")
} catch (error) {
this.sessions.delete(sessionId)
throw error
}
}
/**
* Return recent sessions.
*/
public async recent(): Promise<RecentResponse> {
return {
recent: [], // TODO
running: Array.from(this.sessions).map(([sessionId, session]) => ({
...session.app,
sessionId,
})),
}
}
/**
* For these, just return the error message since they'll be requested as
* JSON.
*/
public async getErrorRoot(_route: Route, _title: string, _header: string, error: string): Promise<HttpResponse> {
return {
content: JSON.stringify({ error }),
}
}
}

130
src/node/app/app.ts Normal file
View File

@ -0,0 +1,130 @@
import { logger } from "@coder/logger"
import * as http from "http"
import * as querystring from "querystring"
import { Application } from "../../common/api"
import { HttpCode, HttpError } from "../../common/http"
import { Options } from "../../common/util"
import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http"
import { ApiHttpProvider } from "./api"
/**
* Top-level and fallback HTTP provider.
*/
export class MainHttpProvider extends HttpProvider {
public constructor(options: HttpProviderOptions, private readonly api: ApiHttpProvider) {
super(options)
}
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse | undefined> {
switch (route.base) {
case "/static": {
this.ensureMethod(request)
const response = await this.getResource(this.rootPath, route.requestPath)
if (!this.isDev) {
response.cache = true
}
return response
}
case "/delete": {
this.ensureMethod(request, "POST")
const data = await this.getData(request)
const p = data ? querystring.parse(data) : {}
this.api.deleteSession(p.sessionId as string)
return { redirect: "/" }
}
case "/": {
this.ensureMethod(request)
if (route.requestPath !== "/index.html") {
throw new HttpError("Not found", HttpCode.NotFound)
} else if (!this.authenticated(request)) {
return { redirect: "/login" }
}
return this.getRoot(route)
}
}
// Run an existing app, but if it doesn't exist go ahead and start it.
let app = this.api.getRunningApplication(route.base)
let sessionId = app && app.sessionId
if (!app) {
app = (await this.api.installedApplications()).find((a) => a.path === route.base)
if (app) {
sessionId = await this.api.createSession(app)
}
}
if (sessionId) {
return this.getAppRoot(
route,
{
sessionId,
base: this.base(route),
logLevel: logger.level,
},
(app && app.name) || ""
)
}
return this.getErrorRoot(route, "404", "404", "Application not found")
}
public async getRoot(route: Route): Promise<HttpResponse> {
const recent = await this.api.recent()
const apps = await this.api.installedApplications()
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/home.html")
response.content = response.content
.replace(/{{COMMIT}}/g, this.options.commit)
.replace(/{{BASE}}/g, this.base(route))
.replace(/{{APP_LIST:RUNNING}}/g, this.getAppRows(recent.running))
.replace(
/{{APP_LIST:EDITORS}}/g,
this.getAppRows(apps.filter((app) => app.categories && app.categories.includes("Editor")))
)
.replace(
/{{APP_LIST:OTHER}}/g,
this.getAppRows(apps.filter((app) => !app.categories || !app.categories.includes("Editor")))
)
return response
}
public async getAppRoot(route: Route, options: Options, name: string): Promise<HttpResponse> {
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/app.html")
response.content = response.content
.replace(/{{COMMIT}}/g, this.options.commit)
.replace(/{{BASE}}/g, this.base(route))
.replace(/{{APP_NAME}}/g, name)
.replace(/"{{OPTIONS}}"/g, `'${JSON.stringify(options)}'`)
return response
}
public async handleWebSocket(): Promise<undefined> {
return undefined
}
private getAppRows(apps: ReadonlyArray<Application>): string {
return apps.length > 0 ? apps.map((app) => this.getAppRow(app)).join("\n") : `<div class="none">None</div>`
}
private getAppRow(app: Application): string {
return `<div class="app-row">
<a class="open" href=".${app.path}">
${
app.icon
? `<img class="icon" src="data:image/png;base64,${app.icon}"></img>`
: `<div class="icon -missing"></div>`
}
<div class="name">${app.name}</div>
</a>
${
app.sessionId
? `<form class="kill-form" action="./delete" method="POST">
<input type="hidden" name="sessionId" value="${app.sessionId}">
<button class="kill" type="submit">Kill</button>
</form>`
: ""
}
</div>`
}
}

27
src/node/app/bin.ts Normal file
View File

@ -0,0 +1,27 @@
import * as path from "path"
import { Application } from "../../common/api"
const getVscodeVersion = (): string => {
try {
return require(path.resolve(__dirname, "../../../lib/vscode/package.json")).version
} catch (error) {
return "unknown"
}
}
export const Vscode: Application = {
categories: ["Editor"],
name: "VS Code",
path: "/vscode",
version: getVscodeVersion(),
}
export const findApplications = async (): Promise<ReadonlyArray<Application>> => {
const apps: Application[] = [Vscode]
return apps.sort((a, b): number => a.name.localeCompare(b.name))
}
export const findWhitelistedApplications = async (): Promise<ReadonlyArray<Application>> => {
return []
}

124
src/node/app/login.ts Normal file
View File

@ -0,0 +1,124 @@
import * as http from "http"
import * as querystring from "querystring"
import { HttpCode, HttpError } from "../../common/http"
import { AuthType, HttpProvider, HttpResponse, Route } from "../http"
import { hash } from "../util"
interface LoginPayload {
password?: string
/**
* Since we must set a cookie with an absolute path, we need to know the full
* base path.
*/
base?: string
}
/**
* Login HTTP provider.
*/
export class LoginHttpProvider extends HttpProvider {
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse | undefined> {
if (this.options.auth !== AuthType.Password) {
throw new HttpError("Not found", HttpCode.NotFound)
}
switch (route.base) {
case "/":
if (route.requestPath !== "/index.html") {
throw new HttpError("Not found", HttpCode.NotFound)
}
switch (request.method) {
case "POST":
return this.tryLogin(route, request)
default:
this.ensureMethod(request)
if (this.authenticated(request)) {
return {
redirect: (Array.isArray(route.query.to) ? route.query.to[0] : route.query.to) || "/",
query: { to: undefined },
}
}
return this.getRoot(route)
}
}
return undefined
}
public async getRoot(route: Route, value?: string, error?: Error): Promise<HttpResponse> {
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/login.html")
response.content = response.content
.replace(/{{COMMIT}}/g, this.options.commit)
.replace(/{{BASE}}/g, this.base(route))
.replace(/{{VALUE}}/g, value || "")
.replace(/{{ERROR}}/g, error ? `<div class="error">${error.message}</div>` : "")
return response
}
/**
* Try logging in. On failure, show the login page with an error.
*/
private async tryLogin(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
// Already authenticated via cookies?
const providedPassword = this.authenticated(request)
if (providedPassword) {
return { code: HttpCode.Ok }
}
let payload: LoginPayload | undefined
try {
const data = await this.getData(request)
const p = data ? querystring.parse(data) : {}
payload = p
return await this.login(p, route, request)
} catch (error) {
return this.getRoot(route, payload ? payload.password : undefined, error)
}
}
/**
* Return a cookie if the user is authenticated otherwise throw an error.
*/
private async login(payload: LoginPayload, route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
const password = this.authenticated(request, {
key: typeof payload.password === "string" ? [hash(payload.password)] : undefined,
})
if (password) {
return {
redirect: (Array.isArray(route.query.to) ? route.query.to[0] : route.query.to) || "/",
query: { to: undefined },
cookie:
typeof password === "string"
? {
key: "key",
value: password,
path: payload.base,
}
: undefined,
}
}
// Only log if it was an actual login attempt.
if (payload && payload.password) {
console.error(
"Failed login attempt",
JSON.stringify({
xForwardedFor: request.headers["x-forwarded-for"],
remoteAddress: request.connection.remoteAddress,
userAgent: request.headers["user-agent"],
timestamp: Math.floor(new Date().getTime() / 1000),
})
)
throw new Error("Incorrect password")
}
throw new Error("Missing password")
}
public async handleWebSocket(): Promise<undefined> {
return undefined
}
}

View File

@ -1,62 +0,0 @@
import { logger } from "@coder/logger"
import * as http from "http"
import * as React from "react"
import * as ReactDOMServer from "react-dom/server"
import App from "../../browser/app"
import { HttpCode, HttpError } from "../../common/http"
import { Options } from "../../common/util"
import { Vscode } from "../api/server"
import { HttpProvider, HttpResponse, Route } from "../http"
/**
* Top-level and fallback HTTP provider.
*/
export class MainHttpProvider extends HttpProvider {
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse | undefined> {
switch (route.base) {
case "/static": {
const response = await this.getResource(this.rootPath, route.requestPath)
if (!this.isDev) {
response.cache = true
}
return response
}
case "/vscode":
case "/": {
if (route.requestPath !== "/index.html") {
throw new HttpError("Not found", HttpCode.NotFound)
}
const options: Options = {
authed: !!this.authenticated(request),
basePath: this.base(route),
logLevel: logger.level,
}
// TODO: Load other apps based on the URL as well.
if (route.base === Vscode.path && options.authed) {
options.app = Vscode
}
return this.getRoot(route, options)
}
}
return undefined
}
public async getRoot(route: Route, options: Options): Promise<HttpResponse> {
const response = await this.getUtf8Resource(this.rootPath, "src/browser/index.html")
response.content = response.content
.replace(/{{COMMIT}}/g, this.options.commit)
.replace(/{{BASE}}/g, this.base(route))
.replace(/"{{OPTIONS}}"/g, `'${JSON.stringify(options)}'`)
.replace(/{{COMPONENT}}/g, ReactDOMServer.renderToString(<App options={options} />))
return response
}
public async handleWebSocket(): Promise<undefined> {
return undefined
}
}

View File

@ -118,18 +118,28 @@ export class VscodeHttpProvider extends HttpProvider {
} }
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse | undefined> { public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse | undefined> {
this.ensureGet(request) this.ensureMethod(request)
this.ensureAuthenticated(request)
switch (route.base) { switch (route.base) {
case "/": case "/":
if (route.requestPath !== "/index.html") { if (route.requestPath !== "/index.html") {
throw new HttpError("Not found", HttpCode.NotFound) throw new HttpError("Not found", HttpCode.NotFound)
} else if (!this.authenticated(request)) {
return { redirect: "/login", query: { to: this.options.base } }
} }
try { try {
return await this.getRoot(request, route) return await this.getRoot(request, route)
} catch (error) { } catch (error) {
return this.getErrorRoot(error) const message = `${
this.isDev ? "It might not have finished compiling (check for 'Finished compilation' in the output)." : ""
} <br><br>${error}`
return this.getErrorRoot(route, "VS Code failed to load", "VS Code failed to load", message)
} }
}
this.ensureAuthenticated(request)
switch (route.base) {
case "/static": { case "/static": {
switch (route.requestPath) { switch (route.requestPath) {
case "/out/vs/workbench/services/extensions/worker/extensionHostWorkerMain.js": { case "/out/vs/workbench/services/extensions/worker/extensionHostWorkerMain.js": {
@ -179,7 +189,7 @@ export class VscodeHttpProvider extends HttpProvider {
remoteAuthority remoteAuthority
) )
const [response, options] = await Promise.all([ const [response, options] = await Promise.all([
await this.getUtf8Resource(this.rootPath, `src/node/vscode/workbench${!this.isDev ? "-build" : ""}.html`), await this.getUtf8Resource(this.rootPath, "src/browser/pages/vscode.html"),
this.initialize({ this.initialize({
args: this.args, args: this.args,
remoteAuthority, remoteAuthority,
@ -195,6 +205,10 @@ export class VscodeHttpProvider extends HttpProvider {
}) })
} }
if (!this.isDev) {
response.content = response.content.replace(/<!-- PROD_ONLY/g, "").replace(/END_PROD_ONLY -->/g, "")
}
return { return {
...response, ...response,
content: response.content content: response.content
@ -208,15 +222,6 @@ export class VscodeHttpProvider extends HttpProvider {
} }
} }
private async getErrorRoot(error: Error): Promise<HttpResponse> {
const response = await this.getUtf8Resource(this.rootPath, "src/node/vscode/error.html")
const message = `VS Code failed to load. ${
this.isDev ? "It might not have finished compiling (check for 'Finished compilation' in the output)." : ""
} <br><br>${error}`
response.content = response.content.replace(/{{COMMIT}}/g, this.options.commit).replace(/{{ERROR}}/g, message)
return response
}
/** /**
* Choose the first valid path. If `workspace` is undefined then either a * Choose the first valid path. If `workspace` is undefined then either a
* workspace or a directory are acceptable. Otherwise it must be a file if a * workspace or a directory are acceptable. Otherwise it must be a file if a

View File

@ -1,13 +1,17 @@
import { logger } from "@coder/logger" import { logger } from "@coder/logger"
import { ApiHttpProvider } from "./api/server"
import { MainHttpProvider } from "./app/server"
import { Args, optionDescriptions, parse } from "./cli" import { Args, optionDescriptions, parse } from "./cli"
import { ApiHttpProvider } from "./app/api"
import { MainHttpProvider } from "./app/app"
import { LoginHttpProvider } from "./app/login"
import { VscodeHttpProvider } from "./app/vscode"
import { AuthType, HttpServer } from "./http" import { AuthType, HttpServer } from "./http"
import { generateCertificate, generatePassword, hash, open } from "./util" import { generateCertificate, generatePassword, hash, open } from "./util"
import { VscodeHttpProvider } from "./vscode/server"
import { ipcMain, wrap } from "./wrapper" import { ipcMain, wrap } from "./wrapper"
const main = async (args: Args): Promise<void> => { const main = async (args: Args): Promise<void> => {
// For any future forking bypass nbin and drop straight to Node.
process.env.NBIN_BYPASS = "true"
const auth = args.auth || AuthType.Password const auth = args.auth || AuthType.Password
const originalPassword = auth === AuthType.Password && (process.env.PASSWORD || (await generatePassword())) const originalPassword = auth === AuthType.Password && (process.env.PASSWORD || (await generatePassword()))
@ -36,9 +40,10 @@ const main = async (args: Args): Promise<void> => {
} }
const httpServer = new HttpServer(options) const httpServer = new HttpServer(options)
httpServer.registerHttpProvider("/", MainHttpProvider) const api = httpServer.registerHttpProvider("/api", ApiHttpProvider, httpServer)
httpServer.registerHttpProvider("/api", ApiHttpProvider, httpServer) httpServer.registerHttpProvider("/vscode", VscodeHttpProvider, args)
httpServer.registerHttpProvider("/vscode-embed", VscodeHttpProvider, args) httpServer.registerHttpProvider("/login", LoginHttpProvider)
httpServer.registerHttpProvider("/", MainHttpProvider, api)
ipcMain().onDispose(() => httpServer.dispose()) ipcMain().onDispose(() => httpServer.dispose())

View File

@ -164,6 +164,17 @@ export abstract class HttpProvider {
return normalize("./" + (depth > 1 ? "../".repeat(depth - 1) : "")) return normalize("./" + (depth > 1 ? "../".repeat(depth - 1) : ""))
} }
public async getErrorRoot(route: Route, title: string, header: string, body: string): Promise<HttpResponse> {
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/error.html")
response.content = response.content
.replace(/{{COMMIT}}/g, this.options.commit)
.replace(/{{BASE}}/g, this.base(route))
.replace(/{{ERROR_TITLE}}/g, title)
.replace(/{{ERROR_HEADER}}/g, header)
.replace(/{{ERROR_BODY}}/g, body)
return response
}
protected get isDev(): boolean { protected get isDev(): boolean {
return this.options.commit === "development" return this.options.commit === "development"
} }
@ -194,10 +205,11 @@ export abstract class HttpProvider {
} }
/** /**
* Helper to error on anything that's not a GET. * Helper to error on invalid methods (default GET).
*/ */
protected ensureGet(request: http.IncomingMessage): void { protected ensureMethod(request: http.IncomingMessage, method?: string | string[]): void {
if (request.method !== "GET") { const check = Array.isArray(method) ? method : [method || "GET"]
if (!request.method || !check.includes(request.method)) {
throw new HttpError(`Unsupported method ${request.method}`, HttpCode.BadRequest) throw new HttpError(`Unsupported method ${request.method}`, HttpCode.BadRequest)
} }
} }
@ -390,14 +402,10 @@ export class HttpServer {
/** /**
* Register a provider for a top-level endpoint. * Register a provider for a top-level endpoint.
*/ */
public registerHttpProvider<T extends HttpProvider>(endpoint: string, provider: HttpProvider0<T>): void public registerHttpProvider<T extends HttpProvider>(endpoint: string, provider: HttpProvider0<T>): T
public registerHttpProvider<A1, T extends HttpProvider>( public registerHttpProvider<A1, T extends HttpProvider>(endpoint: string, provider: HttpProvider1<A1, T>, a1: A1): T
endpoint: string,
provider: HttpProvider1<A1, T>,
a1: A1
): void
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
public registerHttpProvider(endpoint: string, provider: any, a1?: any): void { public registerHttpProvider(endpoint: string, provider: any, a1?: any): any {
endpoint = endpoint.replace(/^\/+|\/+$/g, "") endpoint = endpoint.replace(/^\/+|\/+$/g, "")
if (this.providers.has(`/${endpoint}`)) { if (this.providers.has(`/${endpoint}`)) {
throw new Error(`${endpoint} is already registered`) throw new Error(`${endpoint} is already registered`)
@ -405,18 +413,17 @@ export class HttpServer {
if (/\//.test(endpoint)) { if (/\//.test(endpoint)) {
throw new Error(`Only top-level endpoints are supported (got ${endpoint})`) throw new Error(`Only top-level endpoints are supported (got ${endpoint})`)
} }
this.providers.set( const p = new provider(
`/${endpoint}`, {
new provider( auth: this.options.auth || AuthType.None,
{ base: `/${endpoint}`,
auth: this.options.auth || AuthType.None, commit: this.options.commit,
base: `/${endpoint}`, password: this.options.password,
commit: this.options.commit, },
password: this.options.password, a1
},
a1
)
) )
this.providers.set(`/${endpoint}`, p)
return p
} }
/** /**
@ -451,22 +458,26 @@ export class HttpServer {
} }
private onRequest = async (request: http.IncomingMessage, response: http.ServerResponse): Promise<void> => { private onRequest = async (request: http.IncomingMessage, response: http.ServerResponse): Promise<void> => {
this.heart.beat()
const route = this.parseUrl(request)
try { try {
this.heart.beat()
const route = this.parseUrl(request)
const payload = this.maybeRedirect(request, route) || (await route.provider.handleRequest(route, request)) const payload = this.maybeRedirect(request, route) || (await route.provider.handleRequest(route, request))
if (!payload) { if (!payload) {
throw new HttpError("Not found", HttpCode.NotFound) throw new HttpError("Not found", HttpCode.NotFound)
} }
response.writeHead(payload.redirect ? HttpCode.Redirect : payload.code || HttpCode.Ok, { response.writeHead(payload.redirect ? HttpCode.Redirect : payload.code || HttpCode.Ok, {
"Content-Type": payload.mime || getMediaMime(payload.filePath), "Content-Type": payload.mime || getMediaMime(payload.filePath),
...(payload.redirect ? { Location: payload.redirect } : {}), ...(payload.redirect ? { Location: this.constructRedirect(request, route, payload as RedirectResponse) } : {}),
...(request.headers["service-worker"] ? { "Service-Worker-Allowed": route.provider.base(route) } : {}), ...(request.headers["service-worker"] ? { "Service-Worker-Allowed": route.provider.base(route) } : {}),
...(payload.cache ? { "Cache-Control": "public, max-age=31536000" } : {}), ...(payload.cache ? { "Cache-Control": "public, max-age=31536000" } : {}),
...(payload.cookie ...(payload.cookie
? { ? {
"Set-Cookie": `${payload.cookie.key}=${payload.cookie.value}; Path=${payload.cookie.path || "Set-Cookie": [
"/"}; HttpOnly; SameSite=strict`, `${payload.cookie.key}=${payload.cookie.value}`,
`Path=${normalize(payload.cookie.path || "/", true)}`,
"HttpOnly",
"SameSite=strict",
].join(";"),
} }
: {}), : {}),
...payload.headers, ...payload.headers,
@ -490,37 +501,49 @@ export class HttpServer {
e = new HttpError("Not found", HttpCode.NotFound) e = new HttpError("Not found", HttpCode.NotFound)
} }
logger.debug(error.stack) logger.debug(error.stack)
response.writeHead(typeof e.code === "number" ? e.code : HttpCode.ServerError) const code = typeof e.code === "number" ? e.code : HttpCode.ServerError
response.end(error.message) const content = (await route.provider.getErrorRoot(route, code, code, e.message)).content
response.writeHead(code)
response.end(content)
} }
} }
/** /**
* Return any necessary redirection before delegating to a provider. * Return any necessary redirection before delegating to a provider.
*/ */
private maybeRedirect(request: http.IncomingMessage, route: ProviderRoute): HttpResponse | undefined { private maybeRedirect(request: http.IncomingMessage, route: ProviderRoute): RedirectResponse | undefined {
const redirect = (path: string): string => { // If we're handling TLS ensure all requests are redirected to HTTPS.
Object.keys(route.query).forEach((key) => {
if (typeof route.query[key] === "undefined") {
delete route.query[key]
}
})
// If we're handling TLS ensure all requests are redirected to HTTPS.
return this.options.cert
? `${this.protocol}://${request.headers.host}`
: "" +
normalize(`${route.provider.base(route)}/${path}`, true) +
(Object.keys(route.query).length > 0 ? `?${querystring.stringify(route.query)}` : "")
}
// Redirect to HTTPS if we're handling the TLS.
if (this.options.cert && !(request.connection as tls.TLSSocket).encrypted) { if (this.options.cert && !(request.connection as tls.TLSSocket).encrypted) {
return { redirect: redirect(route.fullPath) } return { redirect: route.fullPath }
} }
return undefined return undefined
} }
/**
* Given a path that goes from the base, construct a relative redirect URL
* that will get you there considering that the app may be served from an
* unknown base path. If handling TLS, also ensure HTTPS.
*/
private constructRedirect(request: http.IncomingMessage, route: ProviderRoute, payload: RedirectResponse): string {
const query = {
...route.query,
...(payload.query || {}),
}
Object.keys(query).forEach((key) => {
if (typeof query[key] === "undefined") {
delete query[key]
}
})
return (
(this.options.cert ? `${this.protocol}://${request.headers.host}` : "") +
normalize(`${route.provider.base(route)}/${payload.redirect}`, true) +
(Object.keys(query).length > 0 ? `?${querystring.stringify(query)}` : "")
)
}
private onUpgrade = async (request: http.IncomingMessage, socket: net.Socket, head: Buffer): Promise<void> => { private onUpgrade = async (request: http.IncomingMessage, socket: net.Socket, head: Buffer): Promise<void> => {
try { try {
this.heart.beat() this.heart.beat()

View File

@ -1,19 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
<meta http-equiv="Content-Security-Policy" content="font-src 'self' fonts.gstatic.com; connect-src 'self'; default-src ws: wss: 'self'; style-src 'self' 'unsafe-inline' fonts.googleapis.com; script-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:;">
<title>code-server</title>
</head>
<body style="background-color:#272727;">
<div id="root" style="color:#f4f4f4;padding:20px;max-width:700px;">
{{ERROR}}
</div>
<script>
if (parent) {
parent.postMessage({ event: "loaded" }, window.location.origin);
}
</script>
</body>
</html>

View File

@ -1,56 +0,0 @@
<!-- Copyright (C) Microsoft Corporation. All rights reserved. -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<!-- Disable pinch zooming -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
<!-- Workbench Configuration -->
<meta id="vscode-workbench-web-configuration" data-settings="{{WORKBENCH_WEB_CONFIGURATION}}">
<!-- Workarounds/Hacks (remote user data uri) -->
<meta id="vscode-remote-user-data-uri" data-settings="{{REMOTE_USER_DATA_URI}}">
<!-- NOTE@coder: Added the commit for use in caching, the product for the
extensions gallery URL, and nls for language support. -->
<meta id="vscode-remote-commit" data-settings="{{COMMIT}}">
<meta id="vscode-remote-product-configuration" data-settings="{{PRODUCT_CONFIGURATION}}">
<meta id="vscode-remote-nls-configuration" data-settings="{{NLS_CONFIGURATION}}">
<!-- Workbench Icon/Manifest/CSS -->
<link rel="icon" href="{{BASE}}/static/src/browser/media/favicon.ico" type="image/x-icon" />
<link rel="manifest" href="{{BASE}}/static/src/browser/media/manifest.json" crossorigin="use-credentials">
<link rel="apple-touch-icon" href="{{BASE}}/static/src/browser/media/code-server.png" />
<meta name="apple-mobile-web-app-capable" content="yes">
</head>
<body aria-label="">
</body>
<!-- Startup (do not modify order of script tags!) -->
<script>
const parts = window.location.pathname.replace(/^\//g, "").split("/")
parts[parts.length - 1] = "{{VS_BASE}}"
const url = new URL(window.location.origin + "/" + parts.join("/"))
const el = document.getElementById('vscode-remote-commit');
const commit = el ? el.getAttribute('data-settings') : "";
const staticBase = url.href + '/static-' + commit;
self.require = {
baseUrl: `${staticBase}/out`,
paths: {
'vscode-textmate': `${staticBase}/node_modules/vscode-textmate/release/main`,
'onigasm-umd': `${staticBase}/node_modules/onigasm-umd/release/main`,
'xterm': `${staticBase}/node_modules/xterm/lib/xterm.js`,
'xterm-addon-search': `${staticBase}/node_modules/xterm-addon-search/lib/xterm-addon-search.js`,
'xterm-addon-web-links': `${staticBase}/node_modules/xterm-addon-web-links/lib/xterm-addon-web-links.js`,
'xterm-addon-webgl': `${staticBase}/node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js`,
'semver-umd': `${staticBase}/node_modules/semver-umd/lib/semver-umd.js`,
},
};
</script>
<script src="{{VS_BASE}}/static/out/vs/loader.js"></script>
<script>
require(['vs/code/browser/workbench/workbench'], function() {});
</script>
</html>

180
yarn.lock
View File

@ -881,14 +881,6 @@
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
"@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": "@types/json-schema@^7.0.3":
version "7.0.4" version "7.0.4"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339"
@ -933,11 +925,6 @@
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
"@types/prop-types@*":
version "15.7.3"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7"
integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==
"@types/q@^1.5.1": "@types/q@^1.5.1":
version "1.5.2" version "1.5.2"
resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8" resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8"
@ -948,21 +935,6 @@
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c" resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c"
integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA== integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==
"@types/react-dom@^16.9.5":
version "16.9.5"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.5.tgz#5de610b04a35d07ffd8f44edad93a71032d9aaa7"
integrity sha512-BX6RQ8s9D+2/gDhxrj8OW+YD4R+8hj7FEM/OJHGNR0KipE1h1mSsf39YeyC81qafkq+N3rU3h3RFbLSwE5VqUg==
dependencies:
"@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"
integrity sha512-LJV97//H+zqKWMms0kvxaKYJDG05U2TtQB3chRLF8MPNs+MQh/H1aGlyDUxjaHvu08EAGerdX2z4LTBc7ns77A==
dependencies:
"@types/prop-types" "*"
csstype "^2.2.0"
"@types/safe-compare@^1.1.0": "@types/safe-compare@^1.1.0":
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/@types/safe-compare/-/safe-compare-1.1.0.tgz#47ed9b9ca51a3a791b431cd59b28f47fa9bf1224" resolved "https://registry.yarnpkg.com/@types/safe-compare/-/safe-compare-1.1.0.tgz#47ed9b9ca51a3a791b431cd59b28f47fa9bf1224"
@ -1228,7 +1200,7 @@ array-equal@^1.0.0:
resolved "https://registry.yarnpkg.com/array-equal/-/array-equal-1.0.0.tgz#8c2a5ef2472fd9ea742b04c77a75093ba2757c93" resolved "https://registry.yarnpkg.com/array-equal/-/array-equal-1.0.0.tgz#8c2a5ef2472fd9ea742b04c77a75093ba2757c93"
integrity sha1-jCpe8kcv2ep0KwTHenUJO6J1fJM= integrity sha1-jCpe8kcv2ep0KwTHenUJO6J1fJM=
array-includes@^3.0.3, array-includes@^3.1.1: array-includes@^3.0.3:
version "3.1.1" version "3.1.1"
resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.1.tgz#cdd67e6852bdf9c1215460786732255ed2459348" resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.1.tgz#cdd67e6852bdf9c1215460786732255ed2459348"
integrity sha512-c2VXaCHl7zPsvpkFsw4nxvFie4fh1ur9bpcgsVkIjqn0H/Xwdg+7fv3n2r/isyS8EBj5b06M9kHyZuIr4El6WQ== integrity sha512-c2VXaCHl7zPsvpkFsw4nxvFie4fh1ur9bpcgsVkIjqn0H/Xwdg+7fv3n2r/isyS8EBj5b06M9kHyZuIr4El6WQ==
@ -2348,11 +2320,6 @@ cssstyle@^1.1.1:
dependencies: dependencies:
cssom "0.3.x" cssom "0.3.x"
csstype@^2.2.0:
version "2.6.8"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.8.tgz#0fb6fc2417ffd2816a418c9336da74d7f07db431"
integrity sha512-msVS9qTuMT5zwAGCVm4mxfrZ18BNc6Csd0oJAtiFMZ1FAx1CCvy2+5MDmYoix63LM/6NDbNtodCiGYGmFgO0dA==
dash-ast@^1.0.0: dash-ast@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/dash-ast/-/dash-ast-1.0.0.tgz#12029ba5fb2f8aa6f0a861795b23c1b4b6c27d37" resolved "https://registry.yarnpkg.com/dash-ast/-/dash-ast-1.0.0.tgz#12029ba5fb2f8aa6f0a861795b23c1b4b6c27d37"
@ -2543,13 +2510,6 @@ doctrine@1.5.0:
esutils "^2.0.2" esutils "^2.0.2"
isarray "^1.0.0" isarray "^1.0.0"
doctrine@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==
dependencies:
esutils "^2.0.2"
doctrine@^3.0.0: doctrine@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961"
@ -2831,27 +2791,6 @@ eslint-plugin-prettier@^3.1.0:
dependencies: dependencies:
prettier-linter-helpers "^1.0.0" prettier-linter-helpers "^1.0.0"
eslint-plugin-react-hooks@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-1.7.0.tgz#6210b6d5a37205f0b92858f895a4e827020a7d04"
integrity sha512-iXTCFcOmlWvw4+TOE8CLWj6yX1GwzT0Y6cUfHHZqWnSk144VmVIRcVGtUAzrLES7C798lmvnt02C7rxaOX1HNA==
eslint-plugin-react@^7.14.3:
version "7.18.3"
resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.18.3.tgz#8be671b7f6be095098e79d27ac32f9580f599bc8"
integrity sha512-Bt56LNHAQCoou88s8ViKRjMB2+36XRejCQ1VoLj716KI1MoE99HpTVvIThJ0rvFmG4E4Gsq+UgToEjn+j044Bg==
dependencies:
array-includes "^3.1.1"
doctrine "^2.1.0"
has "^1.0.3"
jsx-ast-utils "^2.2.3"
object.entries "^1.1.1"
object.fromentries "^2.0.2"
object.values "^1.1.1"
prop-types "^15.7.2"
resolve "^1.14.2"
string.prototype.matchall "^4.0.2"
eslint-scope@^5.0.0: eslint-scope@^5.0.0:
version "5.0.0" version "5.0.0"
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.0.0.tgz#e87c8887c73e8d1ec84f1ca591645c358bfc8fb9" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.0.0.tgz#e87c8887c73e8d1ec84f1ca591645c358bfc8fb9"
@ -3564,11 +3503,6 @@ hmac-drbg@^1.0.0:
minimalistic-assert "^1.0.0" minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.1" minimalistic-crypto-utils "^1.0.1"
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: hosted-git-info@^2.1.4:
version "2.8.5" version "2.8.5"
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.5.tgz#759cfcf2c4d156ade59b0b2dfabddc42a6b9c70c" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.5.tgz#759cfcf2c4d156ade59b0b2dfabddc42a6b9c70c"
@ -3800,15 +3734,6 @@ insert-module-globals@^7.0.0:
undeclared-identifiers "^1.1.2" undeclared-identifiers "^1.1.2"
xtend "^4.0.0" xtend "^4.0.0"
internal-slot@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.2.tgz#9c2e9fb3cd8e5e4256c6f45fe310067fcfa378a3"
integrity sha512-2cQNfwhAfJIkU4KZPkDI+Gj5yNNnbqi40W9Gge6dfnk4TocEVm00B3bdiL+JINrbGJil2TeHvM4rETGzk/f/0g==
dependencies:
es-abstract "^1.17.0-next.1"
has "^1.0.3"
side-channel "^1.0.2"
invariant@^2.2.2, invariant@^2.2.4: invariant@^2.2.2, invariant@^2.2.4:
version "2.2.4" version "2.2.4"
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
@ -4267,14 +4192,6 @@ jsprim@^1.2.2:
json-schema "0.2.3" json-schema "0.2.3"
verror "1.10.0" verror "1.10.0"
jsx-ast-utils@^2.2.3:
version "2.2.3"
resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-2.2.3.tgz#8a9364e402448a3ce7f14d357738310d9248054f"
integrity sha512-EdIHFMm+1BPynpKOpdPqiOsvnIrInRGJD7bzPZdPkjitQEqpdpUuFpq4T0npZFKTiB3RhWFdGN+oqOJIdhDhQA==
dependencies:
array-includes "^3.0.3"
object.assign "^4.1.0"
kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
version "3.2.2" version "3.2.2"
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
@ -4428,7 +4345,7 @@ longest-streak@^2.0.1:
resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-2.0.4.tgz#b8599957da5b5dab64dee3fe316fa774597d90e4" resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-2.0.4.tgz#b8599957da5b5dab64dee3fe316fa774597d90e4"
integrity sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg== integrity sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg==
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: loose-envify@^1.0.0:
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@ -4936,26 +4853,6 @@ object.assign@4.1.0, object.assign@^4.1.0:
has-symbols "^1.0.0" has-symbols "^1.0.0"
object-keys "^1.0.11" object-keys "^1.0.11"
object.entries@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.1.tgz#ee1cf04153de02bb093fec33683900f57ce5399b"
integrity sha512-ilqR7BgdyZetJutmDPfXCDffGa0/Yzl2ivVNpbx/g4UeWrCdRnFDUBrKJGLhGieRHDATnyZXWBeCb29k9CJysQ==
dependencies:
define-properties "^1.1.3"
es-abstract "^1.17.0-next.1"
function-bind "^1.1.1"
has "^1.0.3"
object.fromentries@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.2.tgz#4a09c9b9bb3843dd0f89acdb517a794d4f355ac9"
integrity sha512-r3ZiBH7MQppDJVLx6fhD618GKNG40CZYH9wgwdhKxBDDbQgjeWGGd4AtkZad84d291YxvWe7bJGuE65Anh0dxQ==
dependencies:
define-properties "^1.1.3"
es-abstract "^1.17.0-next.1"
function-bind "^1.1.1"
has "^1.0.3"
object.getownpropertydescriptors@^2.0.3, object.getownpropertydescriptors@^2.1.0: object.getownpropertydescriptors@^2.0.3, object.getownpropertydescriptors@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz#369bf1f9592d8ab89d712dced5cb81c7c5352649" resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz#369bf1f9592d8ab89d712dced5cb81c7c5352649"
@ -4971,7 +4868,7 @@ object.pick@^1.3.0:
dependencies: dependencies:
isobject "^3.0.1" isobject "^3.0.1"
object.values@^1.1.0, object.values@^1.1.1: object.values@^1.1.0:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.1.tgz#68a99ecde356b7e9295a3c5e0ce31dc8c953de5e" resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.1.tgz#68a99ecde356b7e9295a3c5e0ce31dc8c953de5e"
integrity sha512-WTa54g2K8iu0kmS/us18jEmdv1a4Wi//BZ/DTVYEcH0XhLM5NYdpDHja3gt57VrZLcNAO2WGA+KpWsDBaHt6eA== integrity sha512-WTa54g2K8iu0kmS/us18jEmdv1a4Wi//BZ/DTVYEcH0XhLM5NYdpDHja3gt57VrZLcNAO2WGA+KpWsDBaHt6eA==
@ -5859,15 +5756,6 @@ progress@^2.0.0:
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
prop-types@^15.6.2, prop-types@^15.7.2:
version "15.7.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
dependencies:
loose-envify "^1.4.0"
object-assign "^4.1.1"
react-is "^16.8.1"
psl@^1.1.24, psl@^1.1.28: psl@^1.1.24, psl@^1.1.28:
version "1.7.0" version "1.7.0"
resolved "https://registry.yarnpkg.com/psl/-/psl-1.7.0.tgz#f1c4c47a8ef97167dea5d6bbf4816d736e884a3c" resolved "https://registry.yarnpkg.com/psl/-/psl-1.7.0.tgz#f1c4c47a8ef97167dea5d6bbf4816d736e884a3c"
@ -5972,30 +5860,6 @@ range-parser@~1.2.1:
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
react-dom@^16.12.0:
version "16.12.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.12.0.tgz#0da4b714b8d13c2038c9396b54a92baea633fe11"
integrity sha512-LMxFfAGrcS3kETtQaCkTKjMiifahaMySFDn71fZUNpPHZQEzmk/GiAeIT8JSOrHB23fnuCOMruL2a8NYlw+8Gw==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
prop-types "^15.6.2"
scheduler "^0.18.0"
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@^16.12.0:
version "16.12.0"
resolved "https://registry.yarnpkg.com/react/-/react-16.12.0.tgz#0c0a9c6a142429e3614834d5a778e18aa78a0b83"
integrity sha512-fglqy3k5E+81pA8s+7K0/T3DBCF0ZDOher1elBFzF7O6arXJgzyu/FW+COxFvAWXJoJN9KIZbT2LXlukwphYTA==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
prop-types "^15.6.2"
read-only-stream@^2.0.0: read-only-stream@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/read-only-stream/-/read-only-stream-2.0.0.tgz#2724fd6a8113d73764ac288d4386270c1dbf17f0" resolved "https://registry.yarnpkg.com/read-only-stream/-/read-only-stream-2.0.0.tgz#2724fd6a8113d73764ac288d4386270c1dbf17f0"
@ -6124,14 +5988,6 @@ regex-not@^1.0.0, regex-not@^1.0.2:
extend-shallow "^3.0.2" extend-shallow "^3.0.2"
safe-regex "^1.1.0" safe-regex "^1.1.0"
regexp.prototype.flags@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.3.0.tgz#7aba89b3c13a64509dabcf3ca8d9fbb9bdf5cb75"
integrity sha512-2+Q0C5g951OlYlJz6yu5/M33IcsESLlLfsyIaLJaG4FA2r4yP8MvVMJUUP/fVBkSpbbbZlS5gynbEWLipiiXiQ==
dependencies:
define-properties "^1.1.3"
es-abstract "^1.17.0-next.1"
regexpp@^2.0.1: regexpp@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.1.tgz#8d19d31cf632482b589049f8281f93dbcba4d07f" resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.1.tgz#8d19d31cf632482b589049f8281f93dbcba4d07f"
@ -6334,7 +6190,7 @@ resolve@1.1.7:
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b"
integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs= integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=
resolve@^1.1.4, resolve@^1.1.5, resolve@^1.10.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.14.2, resolve@^1.3.2: resolve@^1.1.4, resolve@^1.1.5, resolve@^1.10.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.3.2:
version "1.15.0" version "1.15.0"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.15.0.tgz#1b7ca96073ebb52e741ffd799f6b39ea462c67f5" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.15.0.tgz#1b7ca96073ebb52e741ffd799f6b39ea462c67f5"
integrity sha512-+hTmAldEGE80U2wJJDC1lebb5jWqvTYAfm3YZ1ckk1gBr0MnCqUKlwK1e+anaFljIl+F5tR5IoZcm4ZDA1zMQw== integrity sha512-+hTmAldEGE80U2wJJDC1lebb5jWqvTYAfm3YZ1ckk1gBr0MnCqUKlwK1e+anaFljIl+F5tR5IoZcm4ZDA1zMQw==
@ -6461,14 +6317,6 @@ saxes@^3.1.9:
dependencies: dependencies:
xmlchars "^2.1.1" xmlchars "^2.1.1"
scheduler@^0.18.0:
version "0.18.0"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.18.0.tgz#5901ad6659bc1d8f3fdaf36eb7a67b0d6746b1c4"
integrity sha512-agTSHR1Nbfi6ulI0kYNK0203joW2Y5W4po4l+v03tOoiJKpTBbxpNhWDvqc/4IcOw+KLmSiQLTasZ4cab2/UWQ==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.7.0: "semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.7.0:
version "5.7.1" version "5.7.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
@ -6588,14 +6436,6 @@ shell-quote@^1.6.1:
resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.2.tgz#67a7d02c76c9da24f99d20808fcaded0e0e04be2" resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.2.tgz#67a7d02c76c9da24f99d20808fcaded0e0e04be2"
integrity sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg== integrity sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg==
side-channel@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.2.tgz#df5d1abadb4e4bf4af1cd8852bf132d2f7876947"
integrity sha512-7rL9YlPHg7Ancea1S96Pa8/QWb4BtXL/TZvS6B8XFetGBeuhAsfmUspK6DokBeZ64+Kj9TCNRD/30pVz1BvQNA==
dependencies:
es-abstract "^1.17.0-next.1"
object-inspect "^1.7.0"
signal-exit@^3.0.2: signal-exit@^3.0.2:
version "3.0.2" version "3.0.2"
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
@ -6875,18 +6715,6 @@ string-width@^4.1.0, string-width@^4.2.0:
is-fullwidth-code-point "^3.0.0" is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.0" strip-ansi "^6.0.0"
string.prototype.matchall@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.2.tgz#48bb510326fb9fdeb6a33ceaa81a6ea04ef7648e"
integrity sha512-N/jp6O5fMf9os0JU3E72Qhf590RSRZU/ungsL/qJUYVTNv7hTG0P/dbPjxINVN9jpscu3nzYwKESU3P3RY5tOg==
dependencies:
define-properties "^1.1.3"
es-abstract "^1.17.0"
has-symbols "^1.0.1"
internal-slot "^1.0.2"
regexp.prototype.flags "^1.3.0"
side-channel "^1.0.2"
string.prototype.trimleft@^2.1.1: string.prototype.trimleft@^2.1.1:
version "2.1.1" version "2.1.1"
resolved "https://registry.yarnpkg.com/string.prototype.trimleft/-/string.prototype.trimleft-2.1.1.tgz#9bdb8ac6abd6d602b17a4ed321870d2f8dcefc74" resolved "https://registry.yarnpkg.com/string.prototype.trimleft/-/string.prototype.trimleft-2.1.1.tgz#9bdb8ac6abd6d602b17a4ed321870d2f8dcefc74"