Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli): instance proxy server #635

Merged
merged 7 commits into from
Sep 9, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@
"fs-extra": "^8.1.0",
"gaze": "^1.1.3",
"handlebars": "^4.3.3",
"http-proxy": "^1.18.1",
"i18next-conv": "^9",
"i18next-scanner": "^2.10.3",
"inquirer": "^7.3.3",
"jest-cli": "^24.9.0",
"lodash": "^4.17.11",
"node-http-proxy-json": "^0.1.9",
"parse-author": "^2.0.0",
"parse-gitignore": "^1.0.1",
"styled-jsx": "<3.3.3",
Expand Down
37 changes: 36 additions & 1 deletion cli/src/commands/start.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const i18n = require('../lib/i18n')
const loadEnvFiles = require('../lib/loadEnvFiles')
const parseConfig = require('../lib/parseConfig')
const makePaths = require('../lib/paths')
const createProxyServer = require('../lib/proxy')
const { compileServiceWorker } = require('../lib/pwa')
const makeShell = require('../lib/shell')
const { validatePackage } = require('../lib/validatePackage')
Expand All @@ -18,6 +19,8 @@ const handler = async ({
force,
port = process.env.PORT || defaultPort,
shell: shellSource,
proxy,
proxyPort,
}) => {
const paths = makePaths(cwd)

Expand All @@ -36,6 +39,29 @@ const handler = async ({
process.exit(1)
}

const newPort = await detectPort(port)

if (proxy) {
const newProxyPort = await detectPort(proxyPort)
const proxyBaseUrl = `http://localhost:${newProxyPort}`

reporter.print('')
reporter.info('Starting proxy server...')
reporter.print(
`The proxy for ${chalk.bold(
proxy
)} is now available on port ${newProxyPort}`
)
reporter.print('')

createProxyServer({
target: proxy,
baseUrl: proxyBaseUrl,
port: newProxyPort,
shellPort: newPort,
})
}

await exitOnCatch(
async () => {
if (!(await validatePackage({ config, paths, offerFix: false }))) {
Expand Down Expand Up @@ -77,7 +103,6 @@ const handler = async ({
reporter.info('Generating manifests...')
await generateManifests(paths, config, process.env.PUBLIC_URL)

const newPort = await detectPort(port)
if (String(newPort) !== String(port)) {
reporter.print('')
reporter.warn(
Expand Down Expand Up @@ -123,6 +148,16 @@ const command = {
type: 'number',
description: 'The port to use when running the development server',
},
proxy: {
alias: 'P',
type: 'string',
description: 'The remote DHIS2 instance the proxy should point to',
},
proxyPort: {
type: 'number',
description: 'The port to use when running the proxy',
default: 8080,
},
},
handler,
}
Expand Down
127 changes: 127 additions & 0 deletions cli/src/lib/proxy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
const url = require('url')
const { reporter } = require('@dhis2/cli-helpers-engine')
const httpProxy = require('http-proxy')
const _ = require('lodash')
const transformProxyResponse = require('node-http-proxy-json')

const stripCookieSecure = cookie => {
return cookie
.split(';')
.filter(v => v.trim().toLowerCase() !== 'secure')
.join('; ')
}

const rewriteLocation = ({ location, target, baseUrl }) => {
const parsedLocation = url.parse(location)
const parsedTarget = url.parse(target)
const parsedBaseUrl = url.parse(baseUrl)

if (
parsedLocation.host === parsedTarget.host &&
parsedLocation.pathname.startsWith(parsedTarget.pathname)
) {
return url.format({
...parsedBaseUrl,
pathname: parsedLocation.pathname.replace(
parsedTarget.pathname,
''
),
Comment on lines +25 to +28
Copy link
Member

Choose a reason for hiding this comment

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

Won't this have issues if i.e. the target pathname is /dev and there's a /dev somewhere OTHER than at the start of the string? I.e. we should probably replace /^${pathname}/ instead, so that only the first match is replaced

Copy link
Contributor Author

Choose a reason for hiding this comment

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

When a string is passed to String.prototype.replace, only the first match is replaced (e.g. 'aaa'.replace('a', 'b') === 'baa'). For it to replace multiple matches, a regex with the g flag must be passed ('aaa'.replace(/a/g, 'b') === 'bbb').

search: parsedLocation.search,
})
}
return location
}

const isUrl = string => {
try {
const { protocol } = new URL(string)
return protocol === 'http:' || protocol === 'https:'
} catch (error) {
return false
}
}

const transformJsonResponse = (res, { target, baseUrl }) => {
switch (typeof res) {
case 'string':
if (isUrl(res)) {
return rewriteLocation({ location: res, target, baseUrl })
}
return res
case 'object':
if (Array.isArray(res)) {
return res.map(r =>
transformJsonResponse(r, { target, baseUrl })
)
}
return _.transform(
res,
(result, value, key) => {
result[key] = transformJsonResponse(value, {
target,
baseUrl,
})
},
{}
)
default:
return res
}
}

exports = module.exports = ({ target, baseUrl, port, shellPort }) => {
const proxyServer = httpProxy.createProxyServer({
target,
changeOrigin: true,
secure: false,
protocolRewrite: 'http',
cookieDomainRewrite: '',
cookiePathRewrite: '/',
})

proxyServer.on('proxyRes', (proxyRes, req, res) => {
if (proxyRes.headers['access-control-allow-origin']) {
res.setHeader(
'access-control-allow-origin',
`http://localhost:${shellPort}`
)
}

if (proxyRes.headers.location) {
proxyRes.headers.location = rewriteLocation({
location: proxyRes.headers.location,
target,
baseUrl,
})
}

const sc = proxyRes.headers['set-cookie']
if (Array.isArray(sc)) {
proxyRes.headers['set-cookie'] = sc.map(stripCookieSecure)
}

if (
proxyRes.headers['content-type'] &&
proxyRes.headers['content-type'].includes('application/json')
) {
transformProxyResponse(res, proxyRes, body => {
if (body) {
return transformJsonResponse(body, {
target,
baseUrl,
})
}
return body
})
}
})

proxyServer.on('error', error => {
reporter.warn(error)
})

proxyServer.listen(port)
}

exports.rewriteLocation = rewriteLocation
exports.transformJsonResponse = transformJsonResponse
91 changes: 91 additions & 0 deletions cli/src/lib/proxy.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
const { rewriteLocation, transformJsonResponse } = require('./proxy')

describe('transformJsonResponse', () => {
it('rewrites URLs in responses if they match the proxy target', () => {
const transformedResponse = transformJsonResponse(
{
a: {
b: {
c: 'https://play.dhis2.org/dev/api/endpoint',
},
},
},
{
target: 'https://play.dhis2.org/dev',
baseUrl: 'http://localhost:8080',
}
)

expect(transformedResponse.a.b.c).toBe(
'http://localhost:8080/api/endpoint'
)
})
})

describe('rewriteLocation', () => {
it('rewrites locations if they match the proxy target', () => {
const baseUrl = 'http://localhost:8080'

expect(
rewriteLocation({
location: 'https://play.dhis2.org/dev/login.action',
target: 'https://play.dhis2.org/dev',
baseUrl,
})
).toBe(`${baseUrl}/login.action`)

expect(
rewriteLocation({
location: 'https://play.dhis2.org/dev/page?param=value',
target: 'https://play.dhis2.org/dev',
baseUrl,
})
).toBe(`${baseUrl}/page?param=value`)
mediremi marked this conversation as resolved.
Show resolved Hide resolved

expect(
rewriteLocation({
location: 'https://server.com:1234',
target: 'https://server.com:5678',
baseUrl,
})
).toBe('https://server.com:1234')

expect(
rewriteLocation({
location: 'https://server.com',
target: 'http://server.com',
baseUrl,
})
).toBe(baseUrl)

expect(
rewriteLocation({
location: 'https://play.dhis2.org/dev/api/dev',
target: 'https://play.dhis2.org/dev',
baseUrl,
})
).toBe(`${baseUrl}/api/dev`)
})

it('does not rewrite locations if they do not match the proxy target', () => {
;[
{
location: 'https://example.com/path',
target: 'https://play.dhis2.org/dev',
baseUrl: 'http://localhost:8080',
},
{
location: 'https://play.dhis2.org/2.35dev',
target: 'https://play.dhis2.org/dev',
baseUrl: 'http://localhost:8080',
},
{
location: 'http://server.com:1234',
target: 'http://server.com:5678',
baseUrl: 'http://localhost:8080',
},
].forEach(args => {
expect(rewriteLocation(args)).toBe(args.location)
})
})
})
1 change: 1 addition & 0 deletions docs/_sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@
- [**PWA**](pwa/pwa)
- [**Architecture**](architecture)
- [**Troubleshooting**](troubleshooting.md)
- [**Proxy**](proxy.md)
- &nbsp;
- [Changelog](CHANGELOG.md)
14 changes: 14 additions & 0 deletions docs/proxy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Proxy

When developing against a remote instance, some browsers may block cookies due
to the cross-site nature of requests.

As a workaround, the [`start`](scripts/start.md) command provides a `--proxy`
option. The value of this option is the remote instance that a local proxy will
route requests to.

## Usage

```
d2-app-scripts start --proxy <remote instance URL>
```
2 changes: 2 additions & 0 deletions docs/scripts/start.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,6 @@ Global Options:
Options:
--cwd working directory to use (defaults to cwd)
--port, -p The port to use when running the development server [number]
--proxy, -P The remote DHIS2 instance the proxy should point to [string]
--proxyPort The port to use when running the proxy [number] [default: 8080]
```
7 changes: 5 additions & 2 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ A DHIS2 application needs to talk to a [DHIS2 server](https://www.dhis2.org/down

You should specify the fully-qualified base URL of a running DHIS2 server (including the `http://` or `https://` protocol) in the **Server** field of the login dialog.

Make sure that the the URL of your application (`localhost:3000` in this case) is included in the DHIS2 server's CORS whitelist (System Settings -> Access). Also ensure that the server
you specify doesn't have any domain-restricting proxy settings (such as the `SameSite=Lax`).
Make sure that the the URL of your application (`localhost:3000` in this case)
is included in the DHIS2 server's CORS whitelist (System Settings -> Access). If
your server or browser has domain-restricting settings (such as the
Copy link
Member

Choose a reason for hiding this comment

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

This is actually the case in modern Chrome even if the cookies haven't been explicitly set to restrict domains, since they are now SameSite by default (which is also the DHIS2 default)

`SameSite=Lax`), the [`--proxy`](proxy.md) option of the
[`start`](scripts/start.md) command may be helpful.

For testing purposes, `https://play.dhis2.org/dev` will NOT work because it is configured for secure same-site access, while `https://debug.dhis2.org/dev` should work.

Expand Down
Loading