Skip to content

Commit 28e98c0

Browse files
authored
Merge pull request #2563 from cdr/proxy-path-passthrough-0bb9
pathProxy.ts: Implement --proxy-path-passthrough
2 parents c17f3ff + c32d8b1 commit 28e98c0

13 files changed

+277
-95
lines changed

doc/FAQ.md

+37
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
- [How do I securely access web services?](#how-do-i-securely-access-web-services)
1616
- [Sub-paths](#sub-paths)
1717
- [Sub-domains](#sub-domains)
18+
- [Why does the code-server proxy strip `/proxy/<port>` from the request path?](#why-does-the-code-server-proxy-strip-proxyport-from-the-request-path)
1819
- [Multi-tenancy](#multi-tenancy)
1920
- [Docker in code-server container?](#docker-in-code-server-container)
2021
- [How can I disable telemetry?](#how-can-i-disable-telemetry)
@@ -208,6 +209,42 @@ code-server --proxy-domain <domain>
208209
Now you can browse to `<port>.<domain>`. Note that this uses the host header so
209210
ensure your reverse proxy forwards that information if you are using one.
210211
212+
## Why does the code-server proxy strip `/proxy/<port>` from the request path?
213+
214+
HTTP servers should strive to use relative URLs to avoid needed to be coupled to the
215+
absolute path at which they are served. This means you must use trailing slashes on all
216+
paths with subpaths. See https://blog.cdivilly.com/2019/02/28/uri-trailing-slashes
217+
218+
This is really the "correct" way things work and why the striping of the base path is the
219+
default. If your application uses relative URLs and does not assume the absolute path at
220+
which it is being served, it will just work no matter what port you decide to serve it off
221+
or if you put it in behind code-server or any other proxy!
222+
223+
However many people prefer the cleaner aesthetic of no trailing slashes. This couples you
224+
to the base path as you cannot use relative redirects correctly anymore. See the above
225+
link.
226+
227+
For users who are ok with this tradeoff, pass `--proxy-path-passthrough` to code-server
228+
and the path will be passed as is.
229+
230+
This is particularly a problem with the `start` script in create-react-app. See
231+
[#2222](https://github.com/cdr/code-server/issues/2222). You will need to inform
232+
create-react-app of the path at which you are serving via `homepage` field in your
233+
`package.json`. e.g. you'd add the following for the default CRA port:
234+
235+
```json
236+
"homepage": "/proxy/3000",
237+
```
238+
239+
Then visit `https://my-code-server-address.io/proxy/3000` to see your app exposed through
240+
code-server!
241+
242+
Unfortunately `webpack-dev-server`'s websocket connections will not go through as it
243+
always uses `/sockjs-node`. So hot reloading will not work until the `create-react-app`
244+
team fix this bug.
245+
246+
Highly recommend using the subdomain approach instead to avoid this class of issue.
247+
211248
## Multi-tenancy
212249

213250
If you want to run multiple code-servers on shared infrastructure, we recommend using virtual

package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,13 @@
3838
"@types/js-yaml": "^3.12.3",
3939
"@types/mocha": "^8.0.3",
4040
"@types/node": "^12.12.7",
41+
"@types/node-fetch": "^2.5.7",
4142
"@types/parcel-bundler": "^1.12.1",
4243
"@types/pem": "^1.9.5",
4344
"@types/proxy-from-env": "^1.0.1",
4445
"@types/safe-compare": "^1.1.0",
4546
"@types/semver": "^7.1.0",
4647
"@types/split2": "^2.1.6",
47-
"@types/supertest": "^2.0.10",
4848
"@types/tar-fs": "^2.0.0",
4949
"@types/tar-stream": "^2.1.0",
5050
"@types/ws": "^7.2.6",
@@ -61,7 +61,6 @@
6161
"prettier": "^2.0.5",
6262
"stylelint": "^13.0.0",
6363
"stylelint-config-recommended": "^3.0.0",
64-
"supertest": "^6.0.1",
6564
"ts-node": "^9.0.0",
6665
"typescript": "4.0.2"
6766
},
@@ -81,6 +80,7 @@
8180
"httpolyglot": "^0.1.2",
8281
"js-yaml": "^3.13.1",
8382
"limiter": "^1.1.5",
83+
"node-fetch": "^2.6.1",
8484
"pem": "^1.14.2",
8585
"proxy-agent": "^4.0.0",
8686
"proxy-from-env": "^1.1.0",

src/common/util.ts

+8
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,11 @@ export const getFirstString = (value: string | string[] | object | undefined): s
112112

113113
return typeof value === "string" ? value : undefined
114114
}
115+
116+
export function logError(prefix: string, err: any): void {
117+
if (err instanceof Error) {
118+
logger.error(`${prefix}: ${err.message} ${err.stack}`)
119+
} else {
120+
logger.error(`${prefix}: ${err}`)
121+
}
122+
}

src/node/app.ts

+16-2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import express, { Express } from "express"
33
import { promises as fs } from "fs"
44
import http from "http"
55
import * as httpolyglot from "httpolyglot"
6+
import * as util from "../common/util"
67
import { DefaultedArgs } from "./cli"
78
import { handleUpgrade } from "./wsRouter"
89

@@ -22,8 +23,21 @@ export const createApp = async (args: DefaultedArgs): Promise<[Express, Express,
2223
)
2324
: http.createServer(app)
2425

25-
await new Promise<http.Server>(async (resolve, reject) => {
26-
server.on("error", reject)
26+
let resolved = false
27+
await new Promise<http.Server>(async (resolve2, reject) => {
28+
const resolve = () => {
29+
resolved = true
30+
resolve2()
31+
}
32+
server.on("error", (err) => {
33+
if (!resolved) {
34+
reject(err)
35+
} else {
36+
// Promise resolved earlier so this is an unrelated error.
37+
util.logError("http server error", err)
38+
}
39+
})
40+
2741
if (args.socket) {
2842
try {
2943
await fs.unlink(args.socket)

src/node/cli.ts

+19-2
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export interface Args extends VsArgs {
5050
"show-versions"?: boolean
5151
"uninstall-extension"?: string[]
5252
"proxy-domain"?: string[]
53+
"proxy-path-passthrough"?: boolean
5354
locale?: string
5455
_: string[]
5556
"reuse-window"?: boolean
@@ -172,6 +173,10 @@ const options: Options<Required<Args>> = {
172173
"uninstall-extension": { type: "string[]", description: "Uninstall a VS Code extension by id." },
173174
"show-versions": { type: "boolean", description: "Show VS Code extension versions." },
174175
"proxy-domain": { type: "string[]", description: "Domain used for proxying ports." },
176+
"proxy-path-passthrough": {
177+
type: "boolean",
178+
description: "Whether the path proxy should leave the /proxy/<port> in the request path when proxying.",
179+
},
175180
"ignore-last-opened": {
176181
type: "boolean",
177182
short: "e",
@@ -239,7 +244,7 @@ export const optionDescriptions = (): string[] => {
239244
export const parse = (
240245
argv: string[],
241246
opts?: {
242-
configFile: string
247+
configFile?: string
243248
},
244249
): Args => {
245250
const error = (msg: string): Error => {
@@ -516,7 +521,19 @@ export async function readConfigFile(configPath?: string): Promise<ConfigArgs> {
516521
}
517522

518523
const configFile = await fs.readFile(configPath)
519-
const config = yaml.safeLoad(configFile.toString(), {
524+
return parseConfigFile(configFile.toString(), configPath)
525+
}
526+
527+
/**
528+
* parseConfigFile parses configFile into ConfigArgs.
529+
* configPath is used as the filename in error messages
530+
*/
531+
export function parseConfigFile(configFile: string, configPath: string): ConfigArgs {
532+
if (!configFile) {
533+
return { _: [], config: configPath }
534+
}
535+
536+
const config = yaml.safeLoad(configFile, {
520537
filename: configPath,
521538
})
522539
if (!config || typeof config === "string") {

src/node/heart.ts

+9
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,13 @@ export class Heart {
4545
})
4646
}, this.heartbeatInterval)
4747
}
48+
49+
/**
50+
* Call to clear any heartbeatTimer for shutdown.
51+
*/
52+
public dispose(): void {
53+
if (typeof this.heartbeatTimer !== "undefined") {
54+
clearTimeout(this.heartbeatTimer)
55+
}
56+
}
4857
}

src/node/routes/index.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ export const register = async (
5555
})
5656
})
5757
})
58+
server.on("close", () => {
59+
heart.dispose()
60+
})
5861

5962
app.disable("x-powered-by")
6063
wsApp.disable("x-powered-by")
@@ -165,7 +168,7 @@ export const register = async (
165168

166169
app.use(errorHandler)
167170

168-
const wsErrorHandler: express.ErrorRequestHandler = async (err, req) => {
171+
const wsErrorHandler: express.ErrorRequestHandler = async (err, req, res, next) => {
169172
logger.error(`${err.message} ${err.stack}`)
170173
;(req as WebsocketRequest).ws.end()
171174
}

src/node/routes/pathProxy.ts

+7-7
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ import { Router as WsRouter } from "../wsRouter"
88

99
export const router = Router()
1010

11-
const getProxyTarget = (req: Request, rewrite: boolean): string => {
12-
if (rewrite) {
13-
const query = qs.stringify(req.query)
14-
return `http://0.0.0.0:${req.params.port}/${req.params[0] || ""}${query ? `?${query}` : ""}`
11+
const getProxyTarget = (req: Request, passthroughPath: boolean): string => {
12+
if (passthroughPath) {
13+
return `http://0.0.0.0:${req.params.port}/${req.originalUrl}`
1514
}
16-
return `http://0.0.0.0:${req.params.port}/${req.originalUrl}`
15+
const query = qs.stringify(req.query)
16+
return `http://0.0.0.0:${req.params.port}/${req.params[0] || ""}${query ? `?${query}` : ""}`
1717
}
1818

1919
router.all("/(:port)(/*)?", (req, res) => {
@@ -33,7 +33,7 @@ router.all("/(:port)(/*)?", (req, res) => {
3333

3434
proxy.web(req, res, {
3535
ignorePath: true,
36-
target: getProxyTarget(req, true),
36+
target: getProxyTarget(req, req.args["proxy-path-passthrough"] || false),
3737
})
3838
})
3939

@@ -42,6 +42,6 @@ export const wsRouter = WsRouter()
4242
wsRouter.ws("/(:port)(/*)?", ensureAuthenticated, (req) => {
4343
proxy.ws(req, req.ws, req.head, {
4444
ignorePath: true,
45-
target: getProxyTarget(req, true),
45+
target: getProxyTarget(req, req.args["proxy-path-passthrough"] || false),
4646
})
4747
})

test/httpserver.ts

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import * as http from "http"
2+
import * as nodeFetch from "node-fetch"
3+
import * as util from "../src/common/util"
4+
import { ensureAddress } from "../src/node/app"
5+
6+
// Perhaps an abstraction similar to this should be used in app.ts as well.
7+
export class HttpServer {
8+
private hs = http.createServer()
9+
10+
public constructor(hs?: http.Server) {
11+
// See usage in test/integration.ts
12+
if (hs) {
13+
this.hs = hs
14+
}
15+
}
16+
17+
/**
18+
* listen starts the server on a random localhost port.
19+
* Use close to cleanup when done.
20+
*/
21+
public listen(fn: http.RequestListener): Promise<void> {
22+
this.hs.on("request", fn)
23+
24+
let resolved = false
25+
return new Promise((res, rej) => {
26+
this.hs.listen(0, "localhost", () => {
27+
res()
28+
resolved = true
29+
})
30+
31+
this.hs.on("error", (err) => {
32+
if (!resolved) {
33+
rej(err)
34+
} else {
35+
// Promise resolved earlier so this is some other error.
36+
util.logError("http server error", err)
37+
}
38+
})
39+
})
40+
}
41+
42+
/**
43+
* close cleans up the server.
44+
*/
45+
public close(): Promise<void> {
46+
return new Promise((res, rej) => {
47+
this.hs.close((err) => {
48+
if (err) {
49+
rej(err)
50+
return
51+
}
52+
res()
53+
})
54+
})
55+
}
56+
57+
/**
58+
* fetch fetches the request path.
59+
* The request path must be rooted!
60+
*/
61+
public fetch(requestPath: string, opts?: nodeFetch.RequestInit): Promise<nodeFetch.Response> {
62+
return nodeFetch.default(`${ensureAddress(this.hs)}${requestPath}`, opts)
63+
}
64+
65+
public port(): number {
66+
const addr = this.hs.address()
67+
if (addr && typeof addr === "object") {
68+
return addr.port
69+
}
70+
throw new Error("server not listening or listening on unix socket")
71+
}
72+
}

test/integration.ts

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import * as express from "express"
2+
import { createApp } from "../src/node/app"
3+
import { parse, setDefaults, parseConfigFile, DefaultedArgs } from "../src/node/cli"
4+
import { register } from "../src/node/routes"
5+
import * as httpserver from "./httpserver"
6+
7+
export async function setup(
8+
argv: string[],
9+
configFile?: string,
10+
): Promise<[express.Application, express.Application, httpserver.HttpServer, DefaultedArgs]> {
11+
argv = ["--bind-addr=localhost:0", ...argv]
12+
13+
const cliArgs = parse(argv)
14+
const configArgs = parseConfigFile(configFile || "", "test/integration.ts")
15+
const args = await setDefaults(cliArgs, configArgs)
16+
17+
const [app, wsApp, server] = await createApp(args)
18+
await register(app, wsApp, server, args)
19+
20+
return [app, wsApp, new httpserver.HttpServer(server), args]
21+
}

0 commit comments

Comments
 (0)