Skip to content
This repository has been archived by the owner on Sep 30, 2024. It is now read-only.

web: add web-app server for development and production builds #20126

Merged
merged 21 commits into from
May 7, 2021
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions client/web/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
WEBPACK_SERVE_INDEX=true
# SOURCEGRAPH_API_URL=https://sourcegraph.com
valerybugakov marked this conversation as resolved.
Show resolved Hide resolved
SOURCEGRAPH_API_URL=https://k8s.sgdev.org
SOURCEGRAPH_HTTPS_DOMAIN=sourcegraph.test
SOURCEGRAPH_HTTPS_PORT=3443
# SITE_CONFIG_PATH=./site-config.json
NO_HOT=false
31 changes: 31 additions & 0 deletions client/web/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Web Application

## Local development

### Configuration

1. Duplicate `client/web/.env.example` as `client/web/.env`.
valerybugakov marked this conversation as resolved.
Show resolved Hide resolved
2. Make sure that `WEBPACK_SERVE_INDEX` is set to `true` in the env file.
3. Make sure that `SOURCEGRAPH_API_URL` points to the accessible API url in the env file.

### Development server

```sh
ENTERPRISE=1 yarn serve:dev
valerybugakov marked this conversation as resolved.
Show resolved Hide resolved
```

### Production server

```sh
ENTERPRISE=1 NODE_ENV=production DISABLE_TYPECHECKING=true yarn run build
valerybugakov marked this conversation as resolved.
Show resolved Hide resolved
yarn serve:prod
valerybugakov marked this conversation as resolved.
Show resolved Hide resolved
```

Web app should be available at `http://${SOURCEGRAPH_HTTPS_DOMAIN}:${SOURCEGRAPH_HTTPS_PORT}`.
Build artifacts will be served from `<rootRepoPath>/ui/assets`.

### API proxy

In both environments server proxies API requests to `SOURCEGRAPH_API_URL` provided in the `.env` file.
valerybugakov marked this conversation as resolved.
Show resolved Hide resolved
To avoid the `CSRF token is invalid` error CSRF token is retrieved from the `SOURCEGRAPH_API_URL` before the server starts.
Then this value is used for every subsequent request to the API.
65 changes: 65 additions & 0 deletions client/web/dev/server/development.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import 'dotenv/config'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that we already have quite a few packages, I think this should be it's own package under client/dev-server or whatever. That way, this package will still be only code required in the webapp, and it's not as easy to accidentally import webpack and such into the final web bundle (accidentally imported from this file, for example). Also the dev server needs commonjs as a target, and the webapp can be esnext, so maybe another indicator that a proper package with it's own tsconfig.json file might make sense here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right call. I thought about doing it in this PR. Still, apart from this change, we need to extract shared Webpack configuration into a standalone package to avoid adding loaders to three different configs: Web, Browser, and Storybook. I believe this step should be done before extracting client/dev-server because it might result in duplicated work and circular dependencies between web and dev-server packages. First, move configs to a shared package. Then extract dev-server and use shared configs.


import chalk from 'chalk'
import signale from 'signale'
import createWebpackCompiler, { Configuration } from 'webpack'
import WebpackDevServer, { ProxyConfigArrayItem } from 'webpack-dev-server'

import {
getCSRFTokenCookieMiddleware,
PROXY_ROUTES,
environmentConfig,
getAPIProxySettings,
getCSRFTokenAndCookie,
STATIC_ASSETS_PATH,
STATIC_ASSETS_URL,
WEBPACK_STATS_OPTIONS,
WEB_SERVER_URL,
} from '../utils'

// TODO: migrate webpack.config.js to TS to use `import` in this file.
valerybugakov marked this conversation as resolved.
Show resolved Hide resolved
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
const webpackConfig = require('../../webpack.config') as Configuration
const { SOURCEGRAPH_API_URL, SOURCEGRAPH_HTTPS_PORT, IS_HOT_RELOAD_ENABLED } = environmentConfig

export async function startDevelopmentServer(): Promise<void> {
// Get CSRF token value from the `SOURCEGRAPH_API_URL`.
const { csrfContextValue, csrfCookieValue } = await getCSRFTokenAndCookie(SOURCEGRAPH_API_URL)
signale.await('Development server', { ...environmentConfig, csrfContextValue, csrfCookieValue })

const proxyConfig = {
context: PROXY_ROUTES,
...getAPIProxySettings({
csrfContextValue,
apiURL: SOURCEGRAPH_API_URL,
}),
}

const options: WebpackDevServer.Configuration = {
hot: IS_HOT_RELOAD_ENABLED,
// TODO: resolve https://github.com/webpack/webpack-dev-server/issues/2313 and enable HTTPS.
valerybugakov marked this conversation as resolved.
Show resolved Hide resolved
https: false,
historyApiFallback: true,
port: SOURCEGRAPH_HTTPS_PORT,
publicPath: STATIC_ASSETS_URL,
contentBase: STATIC_ASSETS_PATH,
contentBasePublicPath: [STATIC_ASSETS_URL, '/'],
stats: WEBPACK_STATS_OPTIONS,
noInfo: false,
disableHostCheck: true,
proxy: [proxyConfig as ProxyConfigArrayItem],
before(app) {
app.use(getCSRFTokenCookieMiddleware(csrfCookieValue))
},
}

WebpackDevServer.addDevServerEntrypoints(webpackConfig, options)

const server = new WebpackDevServer(createWebpackCompiler(webpackConfig), options)

server.listen(SOURCEGRAPH_HTTPS_PORT, '0.0.0.0', () => {
signale.success(`Development server is ready at ${chalk.blue.bold(WEB_SERVER_URL)}`)
})
}

startDevelopmentServer().catch(error => signale.error(error))
55 changes: 55 additions & 0 deletions client/web/dev/server/production.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import 'dotenv/config'
valerybugakov marked this conversation as resolved.
Show resolved Hide resolved

import chalk from 'chalk'
import historyApiFallback from 'connect-history-api-fallback'
import express, { RequestHandler } from 'express'
import { createProxyMiddleware } from 'http-proxy-middleware'
import signale from 'signale'

import {
PROXY_ROUTES,
getAPIProxySettings,
getCSRFTokenCookieMiddleware,
environmentConfig,
getCSRFTokenAndCookie,
STATIC_ASSETS_PATH,
WEB_SERVER_URL,
} from '../utils'

const { SOURCEGRAPH_API_URL, SOURCEGRAPH_HTTPS_PORT } = environmentConfig

async function startProductionServer(): Promise<void> {
// Get CSRF token value from the `SOURCEGRAPH_API_URL`.
const { csrfContextValue, csrfCookieValue } = await getCSRFTokenAndCookie(SOURCEGRAPH_API_URL)
signale.await('Production server', { ...environmentConfig, csrfContextValue, csrfCookieValue })

const app = express()

// Serve index.html in place of any 404 responses.
app.use(historyApiFallback() as RequestHandler)
// Attach `CSRF_COOKIE_NAME` cookie to every response to avoid "CSRF token is invalid" API error.
app.use(getCSRFTokenCookieMiddleware(csrfCookieValue))

// Serve index.html.
app.use(express.static(STATIC_ASSETS_PATH))
// Serve build artifacts.
app.use('/.assets', express.static(STATIC_ASSETS_PATH))

// Proxy API requests to the `process.env.SOURCEGRAPH_API_URL`.
app.use(
PROXY_ROUTES,
createProxyMiddleware(
getAPIProxySettings({
// Attach `x-csrf-token` header to every proxy request.
csrfContextValue,
apiURL: SOURCEGRAPH_API_URL,
})
)
)

app.listen(SOURCEGRAPH_HTTPS_PORT, () => {
signale.success(`Production server is ready at ${chalk.blue.bold(WEB_SERVER_URL)}`)
})
}

startProductionServer().catch(error => signale.error(error))
6 changes: 6 additions & 0 deletions client/web/dev/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "commonjs",
},
}
14 changes: 14 additions & 0 deletions client/web/dev/utils/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import path from 'path'

export const ROOT_PATH = path.resolve(__dirname, '../../../../')
export const STATIC_ASSETS_PATH = path.resolve(ROOT_PATH, 'ui/assets')
export const STATIC_ASSETS_URL = '/.assets/'

// TODO: share with gulpfile.js
export const WEBPACK_STATS_OPTIONS = {
all: false,
timings: true,
errors: true,
warnings: true,
colors: true,
}
56 changes: 56 additions & 0 deletions client/web/dev/utils/create-js-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { SourcegraphContext } from '../../src/jscontext'

import { getSiteConfig } from './get-site-config'

// TODO: share with `client/web/src/integration/jscontext` which is not included into `tsconfig.json` now.
export const builtinAuthProvider = {
serviceType: 'builtin' as const,
serviceID: '',
clientID: '',
displayName: 'Builtin username-password authentication',
isBuiltin: true,
authenticationURL: '',
}

// Create dummy JS context that will be added to index.html when `WEBPACK_SERVE_INDEX` is set to true.
export const createJsContext = ({ sourcegraphBaseUrl }: { sourcegraphBaseUrl: string }): SourcegraphContext => {
const siteConfig = getSiteConfig()

if (siteConfig?.authProviders) {
siteConfig.authProviders.unshift(builtinAuthProvider)
}

return {
externalURL: sourcegraphBaseUrl,
accessTokensAllow: 'all-users-create',
allowSignup: true,
batchChangesEnabled: true,
codeIntelAutoIndexingEnabled: false,
externalServicesUserModeEnabled: true,
productResearchPageEnabled: true,
csrfToken: 'qwerty',
assetsRoot: '/.assets',
deployType: 'dev',
debug: true,
emailEnabled: false,
experimentalFeatures: {},
isAuthenticatedUser: true,
likelyDockerOnMac: false,
needServerRestart: false,
needsSiteInit: false,
resetPasswordEnabled: true,
sentryDSN: null,
site: {
'update.channel': 'release',
},
siteID: 'TestSiteID',
siteGQLID: 'TestGQLSiteID',
sourcegraphDotComMode: true,
userAgentIsBot: false,
version: '0.0.0',
xhrHeaders: {},
authProviders: [builtinAuthProvider],
// Site-config overrides default JS context
...siteConfig,
}
}
43 changes: 43 additions & 0 deletions client/web/dev/utils/csrf/get-csrf-token-and-cookie.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import fetch from 'node-fetch'

export const CSRF_CONTEXT_KEY = 'csrfToken'
const CSRF_CONTEXT_VALUE_REGEXP = new RegExp(`${CSRF_CONTEXT_KEY}":"(.*?)"`)

export const CSRF_COOKIE_NAME = 'sg_csrf_token'
const CSRF_COOKIE_VALUE_REGEXP = new RegExp(`${CSRF_COOKIE_NAME}=(.*?);`)

interface CSFRTokenAndCookie {
csrfContextValue: string
csrfCookieValue: string
}

/**
*
* Fetch `${proxyUrl}/sign-in` and extract two values from the response:
*
* 1. `set-cookie` value for `CSRF_COOKIE_NAME`.
* 2. value from JS context under `CSRF_CONTEXT_KEY` key.
*
*/
export async function getCSRFTokenAndCookie(proxyUrl: string): Promise<CSFRTokenAndCookie> {
const response = await fetch(`${proxyUrl}/sign-in`)

const html = await response.text()
const cookieHeader = response.headers.get('set-cookie')

if (!cookieHeader) {
throw new Error(`"set-cookie" header not found in "${proxyUrl}/sign-in" response`)
}

const csrfHeaderMatches = CSRF_CONTEXT_VALUE_REGEXP.exec(html)
const csrfCookieMatches = CSRF_COOKIE_VALUE_REGEXP.exec(cookieHeader)

if (!csrfHeaderMatches || !csrfCookieMatches) {
throw new Error('CSRF value not found!')
}

return {
csrfContextValue: csrfHeaderMatches[1],
csrfCookieValue: csrfCookieMatches[1],
}
}
9 changes: 9 additions & 0 deletions client/web/dev/utils/csrf/get-csrf-token-cookie-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { RequestHandler } from 'express'

import { CSRF_COOKIE_NAME } from './get-csrf-token-and-cookie'

// Attach `CSRF_COOKIE_NAME` cookie to every response to avoid "CSRF token is invalid" API error.
export const getCSRFTokenCookieMiddleware = (csrfCookieValue: string): RequestHandler => (_request, response, next) => {
response.cookie(CSRF_COOKIE_NAME, csrfCookieValue, { httpOnly: true })
next()
}
2 changes: 2 additions & 0 deletions client/web/dev/utils/csrf/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './get-csrf-token-and-cookie'
export * from './get-csrf-token-cookie-middleware'
21 changes: 21 additions & 0 deletions client/web/dev/utils/environment-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import path from 'path'

import { ROOT_PATH } from './constants'

const DEFAULT_SITE_CONFIG_PATH = path.resolve(ROOT_PATH, '../dev-private/enterprise/dev/site-config.json')

export const environmentConfig = {
NODE_ENV: process.env.NODE_ENV || 'development',
SOURCEGRAPH_API_URL: process.env.SOURCEGRAPH_API_URL || 'https://k8s.sgdev.org',
SOURCEGRAPH_HTTPS_DOMAIN: process.env.SOURCEGRAPH_HTTPS_DOMAIN || 'sourcegraph.test',
SOURCEGRAPH_HTTPS_PORT: Number(process.env.SOURCEGRAPH_HTTPS_PORT) || 3443,
WEBPACK_SERVE_INDEX: process.env.WEBPACK_SERVE_INDEX === 'true',
SITE_CONFIG_PATH: process.env.SITE_CONFIG_PATH || DEFAULT_SITE_CONFIG_PATH,

// TODO: do we use process.env.NO_HOT anywhere?
IS_HOT_RELOAD_ENABLED: process.env.NO_HOT !== 'true',
}

const { SOURCEGRAPH_HTTPS_PORT, SOURCEGRAPH_HTTPS_DOMAIN } = environmentConfig

export const WEB_SERVER_URL = `http://${SOURCEGRAPH_HTTPS_DOMAIN}:${SOURCEGRAPH_HTTPS_PORT}`
42 changes: 42 additions & 0 deletions client/web/dev/utils/get-api-proxy-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Options } from 'http-proxy-middleware'

// One of the API routes: "/-/sign-in".
export const PROXY_ROUTES = ['/.api', '/search/stream', '/-', '/.auth']

interface GetAPIProxySettingsOptions {
csrfContextValue: string
apiURL: string
}

export const getAPIProxySettings = ({ csrfContextValue, apiURL }: GetAPIProxySettingsOptions): Options => ({
valerybugakov marked this conversation as resolved.
Show resolved Hide resolved
target: apiURL,
// Do not SSL certificate.
secure: false,
// Change the origin of the host header to the target URL.
changeOrigin: true,
// Attach `x-csrf-token` header to every request to avoid "CSRF token is invalid" API error.
headers: {
'x-csrf-token': csrfContextValue,
},
// Rewrite domain of `set-cookie` headers for all cookies received.
cookieDomainRewrite: '',
onProxyRes: proxyResponse => {
if (proxyResponse.headers['set-cookie']) {
// Remove `Secure` and `SameSite` from `set-cookie` headers.
const cookies = proxyResponse.headers['set-cookie'].map(cookie =>
cookie.replace(/; secure/gi, '').replace(/; samesite=.+/gi, '')
)

proxyResponse.headers['set-cookie'] = cookies
}
},
// TODO: share with `client/web/gulpfile.js`
// Avoid crashing on "read ECONNRESET".
onError: () => undefined,
// Don't log proxy errors, these usually just contain
// ECONNRESET errors caused by the browser cancelling
// requests. This should not be needed to actually debug something.
logLevel: 'silent',
onProxyReqWs: (_proxyRequest, _request, socket) =>
socket.on('error', error => console.error('WebSocket proxy error:', error)),
})
25 changes: 25 additions & 0 deletions client/web/dev/utils/get-site-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import fs from 'fs'

import { parse } from '@sqs/jsonc-parser'
import lodash from 'lodash'

import { SourcegraphContext } from '../../src/jscontext'

import { environmentConfig } from './environment-config'

const { SITE_CONFIG_PATH } = environmentConfig

// Get site-config from `SITE_CONFIG_PATH` as an object with camel cased keys.
export const getSiteConfig = (): Partial<SourcegraphContext> => {
try {
// eslint-disable-next-line no-sync
const siteConfig = parse(fs.readFileSync(SITE_CONFIG_PATH, 'utf-8'))

return lodash.mapKeys(siteConfig, (_value, key) => lodash.camelCase(key))
valerybugakov marked this conversation as resolved.
Show resolved Hide resolved
} catch (error) {
console.log('Site config not found!', SITE_CONFIG_PATH)
console.error(error)

return {}
}
}
5 changes: 5 additions & 0 deletions client/web/dev/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './constants'
export * from './create-js-context'
export * from './environment-config'
export * from './get-api-proxy-settings'
export * from './csrf'
Loading