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

Commit 1d8ff5e

Browse files
valerybugakovvovakulikov
authored andcommitted
web: add web-app server for development and production builds (#20126)
This PR introduces an independent server for the web application, which can use any deployed API instance as a backend.
1 parent 1b0e969 commit 1d8ff5e

21 files changed

+562
-36
lines changed

client/web/README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Web Application
2+
3+
## Local development
4+
5+
Use `sg` CLI tool to configure and start local development server. For more information checkout `sg` [README]('../../dev/sg/README.md').
6+
7+
### Configuration
8+
9+
Environment variables important for the web server:
10+
11+
1. `WEBPACK_SERVE_INDEX` should be set to `true` to enable `HTMLWebpackPlugin`.
12+
2. `SOURCEGRAPH_API_URL` is used as a proxied API url. By default it points to the [https://k8s.sgdev.org](https://k8s.sgdev.org).
13+
14+
It's possible to overwrite these variables by creating `sg.config.overwrite.yaml` in the root folder and adjusting the `env` section of the relevant command.
15+
16+
### Development server
17+
18+
```sh
19+
sg run web-standalone
20+
```
21+
22+
For enterprise version:
23+
24+
```sh
25+
sg run enterprise-web-standalone
26+
```
27+
28+
### Production server
29+
30+
```sh
31+
sg run web-standalone-prod
32+
```
33+
34+
For enterprise version:
35+
36+
```sh
37+
sg run enterprise-web-standalone-prod
38+
```
39+
40+
Web app should be available at `http://${SOURCEGRAPH_HTTPS_DOMAIN}:${SOURCEGRAPH_HTTPS_PORT}`.
41+
Build artifacts will be served from `<rootRepoPath>/ui/assets`.
42+
43+
### API proxy
44+
45+
In both environments, server proxies API requests to `SOURCEGRAPH_API_URL` provided as the `.env` variable.
46+
To avoid the `CSRF token is invalid` error CSRF token is retrieved from the `SOURCEGRAPH_API_URL` before the server starts.
47+
Then this value is used for every subsequent request to the API.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import chalk from 'chalk'
2+
import signale from 'signale'
3+
import createWebpackCompiler, { Configuration } from 'webpack'
4+
import WebpackDevServer, { ProxyConfigArrayItem } from 'webpack-dev-server'
5+
6+
import {
7+
getCSRFTokenCookieMiddleware,
8+
PROXY_ROUTES,
9+
environmentConfig,
10+
getAPIProxySettings,
11+
getCSRFTokenAndCookie,
12+
STATIC_ASSETS_PATH,
13+
STATIC_ASSETS_URL,
14+
WEBPACK_STATS_OPTIONS,
15+
WEB_SERVER_URL,
16+
} from '../utils'
17+
18+
// TODO: migrate webpack.config.js to TS to use `import` in this file.
19+
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
20+
const webpackConfig = require('../../webpack.config') as Configuration
21+
const { SOURCEGRAPH_API_URL, SOURCEGRAPH_HTTPS_PORT, IS_HOT_RELOAD_ENABLED } = environmentConfig
22+
23+
export async function startDevelopmentServer(): Promise<void> {
24+
// Get CSRF token value from the `SOURCEGRAPH_API_URL`.
25+
const { csrfContextValue, csrfCookieValue } = await getCSRFTokenAndCookie(SOURCEGRAPH_API_URL)
26+
signale.await('Development server', { ...environmentConfig, csrfContextValue, csrfCookieValue })
27+
28+
const proxyConfig = {
29+
context: PROXY_ROUTES,
30+
...getAPIProxySettings({
31+
csrfContextValue,
32+
apiURL: SOURCEGRAPH_API_URL,
33+
}),
34+
}
35+
36+
const options: WebpackDevServer.Configuration = {
37+
hot: IS_HOT_RELOAD_ENABLED,
38+
// TODO: resolve https://github.com/webpack/webpack-dev-server/issues/2313 and enable HTTPS.
39+
https: false,
40+
historyApiFallback: true,
41+
port: SOURCEGRAPH_HTTPS_PORT,
42+
publicPath: STATIC_ASSETS_URL,
43+
contentBase: STATIC_ASSETS_PATH,
44+
contentBasePublicPath: [STATIC_ASSETS_URL, '/'],
45+
stats: WEBPACK_STATS_OPTIONS,
46+
noInfo: false,
47+
disableHostCheck: true,
48+
proxy: [proxyConfig as ProxyConfigArrayItem],
49+
before(app) {
50+
app.use(getCSRFTokenCookieMiddleware(csrfCookieValue))
51+
},
52+
}
53+
54+
WebpackDevServer.addDevServerEntrypoints(webpackConfig, options)
55+
56+
const server = new WebpackDevServer(createWebpackCompiler(webpackConfig), options)
57+
58+
server.listen(SOURCEGRAPH_HTTPS_PORT, '0.0.0.0', () => {
59+
signale.success(`Development server is ready at ${chalk.blue.bold(WEB_SERVER_URL)}`)
60+
})
61+
}
62+
63+
startDevelopmentServer().catch(error => signale.error(error))
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import chalk from 'chalk'
2+
import historyApiFallback from 'connect-history-api-fallback'
3+
import express, { RequestHandler } from 'express'
4+
import { createProxyMiddleware } from 'http-proxy-middleware'
5+
import signale from 'signale'
6+
7+
import {
8+
PROXY_ROUTES,
9+
getAPIProxySettings,
10+
getCSRFTokenCookieMiddleware,
11+
environmentConfig,
12+
getCSRFTokenAndCookie,
13+
STATIC_ASSETS_PATH,
14+
WEB_SERVER_URL,
15+
} from '../utils'
16+
17+
const { SOURCEGRAPH_API_URL, SOURCEGRAPH_HTTPS_PORT } = environmentConfig
18+
19+
async function startProductionServer(): Promise<void> {
20+
// Get CSRF token value from the `SOURCEGRAPH_API_URL`.
21+
const { csrfContextValue, csrfCookieValue } = await getCSRFTokenAndCookie(SOURCEGRAPH_API_URL)
22+
signale.await('Production server', { ...environmentConfig, csrfContextValue, csrfCookieValue })
23+
24+
const app = express()
25+
26+
// Serve index.html in place of any 404 responses.
27+
app.use(historyApiFallback() as RequestHandler)
28+
// Attach `CSRF_COOKIE_NAME` cookie to every response to avoid "CSRF token is invalid" API error.
29+
app.use(getCSRFTokenCookieMiddleware(csrfCookieValue))
30+
31+
// Serve index.html.
32+
app.use(express.static(STATIC_ASSETS_PATH))
33+
// Serve build artifacts.
34+
app.use('/.assets', express.static(STATIC_ASSETS_PATH))
35+
36+
// Proxy API requests to the `process.env.SOURCEGRAPH_API_URL`.
37+
app.use(
38+
PROXY_ROUTES,
39+
createProxyMiddleware(
40+
getAPIProxySettings({
41+
// Attach `x-csrf-token` header to every proxy request.
42+
csrfContextValue,
43+
apiURL: SOURCEGRAPH_API_URL,
44+
})
45+
)
46+
)
47+
48+
app.listen(SOURCEGRAPH_HTTPS_PORT, () => {
49+
signale.success(`Production server is ready at ${chalk.blue.bold(WEB_SERVER_URL)}`)
50+
})
51+
}
52+
53+
startProductionServer().catch(error => signale.error(error))

client/web/dev/tsconfig.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"extends": "../tsconfig.json",
3+
"compilerOptions": {
4+
"module": "commonjs",
5+
},
6+
}

client/web/dev/utils/constants.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import path from 'path'
2+
3+
export const ROOT_PATH = path.resolve(__dirname, '../../../../')
4+
export const STATIC_ASSETS_PATH = path.resolve(ROOT_PATH, 'ui/assets')
5+
export const STATIC_ASSETS_URL = '/.assets/'
6+
7+
// TODO: share with gulpfile.js
8+
export const WEBPACK_STATS_OPTIONS = {
9+
all: false,
10+
timings: true,
11+
errors: true,
12+
warnings: true,
13+
colors: true,
14+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { SourcegraphContext } from '../../src/jscontext'
2+
3+
import { getSiteConfig } from './get-site-config'
4+
5+
// TODO: share with `client/web/src/integration/jscontext` which is not included into `tsconfig.json` now.
6+
export const builtinAuthProvider = {
7+
serviceType: 'builtin' as const,
8+
serviceID: '',
9+
clientID: '',
10+
displayName: 'Builtin username-password authentication',
11+
isBuiltin: true,
12+
authenticationURL: '',
13+
}
14+
15+
// Create dummy JS context that will be added to index.html when `WEBPACK_SERVE_INDEX` is set to true.
16+
export const createJsContext = ({ sourcegraphBaseUrl }: { sourcegraphBaseUrl: string }): SourcegraphContext => {
17+
const siteConfig = getSiteConfig()
18+
19+
if (siteConfig?.authProviders) {
20+
siteConfig.authProviders.unshift(builtinAuthProvider)
21+
}
22+
23+
return {
24+
externalURL: sourcegraphBaseUrl,
25+
accessTokensAllow: 'all-users-create',
26+
allowSignup: true,
27+
batchChangesEnabled: true,
28+
codeIntelAutoIndexingEnabled: false,
29+
externalServicesUserModeEnabled: true,
30+
productResearchPageEnabled: true,
31+
csrfToken: 'qwerty',
32+
assetsRoot: '/.assets',
33+
deployType: 'dev',
34+
debug: true,
35+
emailEnabled: false,
36+
experimentalFeatures: {},
37+
isAuthenticatedUser: true,
38+
likelyDockerOnMac: false,
39+
needServerRestart: false,
40+
needsSiteInit: false,
41+
resetPasswordEnabled: true,
42+
sentryDSN: null,
43+
site: {
44+
'update.channel': 'release',
45+
},
46+
siteID: 'TestSiteID',
47+
siteGQLID: 'TestGQLSiteID',
48+
sourcegraphDotComMode: true,
49+
userAgentIsBot: false,
50+
version: '0.0.0',
51+
xhrHeaders: {},
52+
authProviders: [builtinAuthProvider],
53+
// Site-config overrides default JS context
54+
...siteConfig,
55+
}
56+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import fetch from 'node-fetch'
2+
3+
export const CSRF_CONTEXT_KEY = 'csrfToken'
4+
const CSRF_CONTEXT_VALUE_REGEXP = new RegExp(`${CSRF_CONTEXT_KEY}":"(.*?)"`)
5+
6+
export const CSRF_COOKIE_NAME = 'sg_csrf_token'
7+
const CSRF_COOKIE_VALUE_REGEXP = new RegExp(`${CSRF_COOKIE_NAME}=(.*?);`)
8+
9+
interface CSFRTokenAndCookie {
10+
csrfContextValue: string
11+
csrfCookieValue: string
12+
}
13+
14+
/**
15+
*
16+
* Fetch `${proxyUrl}/sign-in` and extract two values from the response:
17+
*
18+
* 1. `set-cookie` value for `CSRF_COOKIE_NAME`.
19+
* 2. value from JS context under `CSRF_CONTEXT_KEY` key.
20+
*
21+
*/
22+
export async function getCSRFTokenAndCookie(proxyUrl: string): Promise<CSFRTokenAndCookie> {
23+
const response = await fetch(`${proxyUrl}/sign-in`)
24+
25+
const html = await response.text()
26+
const cookieHeader = response.headers.get('set-cookie')
27+
28+
if (!cookieHeader) {
29+
throw new Error(`"set-cookie" header not found in "${proxyUrl}/sign-in" response`)
30+
}
31+
32+
const csrfHeaderMatches = CSRF_CONTEXT_VALUE_REGEXP.exec(html)
33+
const csrfCookieMatches = CSRF_COOKIE_VALUE_REGEXP.exec(cookieHeader)
34+
35+
if (!csrfHeaderMatches || !csrfCookieMatches) {
36+
throw new Error('CSRF value not found!')
37+
}
38+
39+
return {
40+
csrfContextValue: csrfHeaderMatches[1],
41+
csrfCookieValue: csrfCookieMatches[1],
42+
}
43+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { RequestHandler } from 'express'
2+
3+
import { CSRF_COOKIE_NAME } from './get-csrf-token-and-cookie'
4+
5+
// Attach `CSRF_COOKIE_NAME` cookie to every response to avoid "CSRF token is invalid" API error.
6+
export const getCSRFTokenCookieMiddleware = (csrfCookieValue: string): RequestHandler => (_request, response, next) => {
7+
response.cookie(CSRF_COOKIE_NAME, csrfCookieValue, { httpOnly: true })
8+
next()
9+
}

client/web/dev/utils/csrf/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './get-csrf-token-and-cookie'
2+
export * from './get-csrf-token-cookie-middleware'
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import path from 'path'
2+
3+
import { ROOT_PATH } from './constants'
4+
5+
const DEFAULT_SITE_CONFIG_PATH = path.resolve(ROOT_PATH, '../dev-private/enterprise/dev/site-config.json')
6+
7+
export const environmentConfig = {
8+
NODE_ENV: process.env.NODE_ENV || 'development',
9+
SOURCEGRAPH_API_URL: process.env.SOURCEGRAPH_API_URL || 'https://k8s.sgdev.org',
10+
SOURCEGRAPH_HTTPS_DOMAIN: process.env.SOURCEGRAPH_HTTPS_DOMAIN || 'sourcegraph.test',
11+
SOURCEGRAPH_HTTPS_PORT: Number(process.env.SOURCEGRAPH_HTTPS_PORT) || 3443,
12+
WEBPACK_SERVE_INDEX: process.env.WEBPACK_SERVE_INDEX === 'true',
13+
SITE_CONFIG_PATH: process.env.SITE_CONFIG_PATH || DEFAULT_SITE_CONFIG_PATH,
14+
ENTERPRISE: Boolean(process.env.ENTERPRISE),
15+
16+
// TODO: do we use process.env.NO_HOT anywhere?
17+
IS_HOT_RELOAD_ENABLED: process.env.NO_HOT !== 'true',
18+
}
19+
20+
const { SOURCEGRAPH_HTTPS_PORT, SOURCEGRAPH_HTTPS_DOMAIN } = environmentConfig
21+
22+
export const WEB_SERVER_URL = `http://${SOURCEGRAPH_HTTPS_DOMAIN}:${SOURCEGRAPH_HTTPS_PORT}`
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Options } from 'http-proxy-middleware'
2+
3+
// One of the API routes: "/-/sign-in".
4+
export const PROXY_ROUTES = ['/.api', '/search/stream', '/-', '/.auth']
5+
6+
interface GetAPIProxySettingsOptions {
7+
csrfContextValue: string
8+
apiURL: string
9+
}
10+
11+
export const getAPIProxySettings = ({ csrfContextValue, apiURL }: GetAPIProxySettingsOptions): Options => ({
12+
target: apiURL,
13+
// Do not SSL certificate.
14+
secure: false,
15+
// Change the origin of the host header to the target URL.
16+
changeOrigin: true,
17+
// Attach `x-csrf-token` header to every request to avoid "CSRF token is invalid" API error.
18+
headers: {
19+
'x-csrf-token': csrfContextValue,
20+
},
21+
// Rewrite domain of `set-cookie` headers for all cookies received.
22+
cookieDomainRewrite: '',
23+
onProxyRes: proxyResponse => {
24+
if (proxyResponse.headers['set-cookie']) {
25+
// Remove `Secure` and `SameSite` from `set-cookie` headers.
26+
const cookies = proxyResponse.headers['set-cookie'].map(cookie =>
27+
cookie.replace(/; secure/gi, '').replace(/; samesite=.+/gi, '')
28+
)
29+
30+
proxyResponse.headers['set-cookie'] = cookies
31+
}
32+
},
33+
// TODO: share with `client/web/gulpfile.js`
34+
// Avoid crashing on "read ECONNRESET".
35+
onError: () => undefined,
36+
// Don't log proxy errors, these usually just contain
37+
// ECONNRESET errors caused by the browser cancelling
38+
// requests. This should not be needed to actually debug something.
39+
logLevel: 'silent',
40+
onProxyReqWs: (_proxyRequest, _request, socket) =>
41+
socket.on('error', error => console.error('WebSocket proxy error:', error)),
42+
})
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import fs from 'fs'
2+
3+
import { parse } from '@sqs/jsonc-parser'
4+
import lodash from 'lodash'
5+
6+
import { SourcegraphContext } from '../../src/jscontext'
7+
8+
import { environmentConfig } from './environment-config'
9+
10+
const { SITE_CONFIG_PATH } = environmentConfig
11+
12+
// Get site-config from `SITE_CONFIG_PATH` as an object with camel cased keys.
13+
export const getSiteConfig = (): Partial<SourcegraphContext> => {
14+
try {
15+
// eslint-disable-next-line no-sync
16+
const siteConfig = parse(fs.readFileSync(SITE_CONFIG_PATH, 'utf-8'))
17+
18+
return lodash.mapKeys(siteConfig, (_value, key) => lodash.camelCase(key))
19+
} catch (error) {
20+
console.log('Site config not found!', SITE_CONFIG_PATH)
21+
console.error(error)
22+
23+
return {}
24+
}
25+
}

client/web/dev/utils/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export * from './constants'
2+
export * from './create-js-context'
3+
export * from './environment-config'
4+
export * from './get-api-proxy-settings'
5+
export * from './csrf'

0 commit comments

Comments
 (0)