-
Notifications
You must be signed in to change notification settings - Fork 1.3k
web: add web-app server for development and production builds #20126
Changes from 18 commits
1a49662
477953a
c42b41c
8d5103f
5400089
70ecbcb
fcb215e
c6d7dad
7b21808
32888cf
62df534
e6f695f
ec50595
0f1fbec
c7a4407
81489e3
582f918
624fffa
f857054
3eb7ee9
2dac074
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
WEBPACK_SERVE_INDEX=true | ||
# SOURCEGRAPH_API_URL=https://sourcegraph.com | ||
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 |
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. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
import 'dotenv/config' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
||
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)) |
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)) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
{ | ||
"extends": "../tsconfig.json", | ||
"compilerOptions": { | ||
"module": "commonjs", | ||
}, | ||
} |
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, | ||
} |
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, | ||
} | ||
} |
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], | ||
} | ||
} |
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() | ||
} |
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' |
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}` |
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)), | ||
}) |
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 {} | ||
} | ||
} |
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' |
Uh oh!
There was an error while loading. Please reload this page.