From f169e3ac665c9180455f66a5acea0f2257837c66 Mon Sep 17 00:00:00 2001 From: Anmol Sethi Date: Mon, 11 Jan 2021 13:17:45 -0500 Subject: [PATCH 01/12] pathProxy.ts: Implement --proxy-path-passthrough Closes #2222 --- src/node/cli.ts | 5 +++++ src/node/routes/pathProxy.ts | 14 +++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/node/cli.ts b/src/node/cli.ts index 1653e87a..5a0e4473 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -50,6 +50,7 @@ export interface Args extends VsArgs { "show-versions"?: boolean "uninstall-extension"?: string[] "proxy-domain"?: string[] + "proxy-path-passthrough"?: boolean locale?: string _: string[] "reuse-window"?: boolean @@ -172,6 +173,10 @@ const options: Options> = { "uninstall-extension": { type: "string[]", description: "Uninstall a VS Code extension by id." }, "show-versions": { type: "boolean", description: "Show VS Code extension versions." }, "proxy-domain": { type: "string[]", description: "Domain used for proxying ports." }, + "proxy-path-passthrough": { + type: "boolean", + description: "Whether the path proxy should leave the /proxy/ in the request path when proxying.", + }, "ignore-last-opened": { type: "boolean", short: "e", diff --git a/src/node/routes/pathProxy.ts b/src/node/routes/pathProxy.ts index 15240219..9554a006 100644 --- a/src/node/routes/pathProxy.ts +++ b/src/node/routes/pathProxy.ts @@ -8,12 +8,12 @@ import { Router as WsRouter } from "../wsRouter" export const router = Router() -const getProxyTarget = (req: Request, rewrite: boolean): string => { - if (rewrite) { - const query = qs.stringify(req.query) - return `http://0.0.0.0:${req.params.port}/${req.params[0] || ""}${query ? `?${query}` : ""}` +const getProxyTarget = (req: Request, passthroughPath: boolean): string => { + if (passthroughPath) { + return `http://0.0.0.0:${req.params.port}/${req.originalUrl}` } - return `http://0.0.0.0:${req.params.port}/${req.originalUrl}` + const query = qs.stringify(req.query) + return `http://0.0.0.0:${req.params.port}/${req.params[0] || ""}${query ? `?${query}` : ""}` } router.all("/(:port)(/*)?", (req, res) => { @@ -33,7 +33,7 @@ router.all("/(:port)(/*)?", (req, res) => { proxy.web(req, res, { ignorePath: true, - target: getProxyTarget(req, true), + target: getProxyTarget(req, req.args["proxy-path-passthrough"] || false), }) }) @@ -42,6 +42,6 @@ export const wsRouter = WsRouter() wsRouter.ws("/(:port)(/*)?", ensureAuthenticated, (req) => { proxy.ws(req, req.ws, req.head, { ignorePath: true, - target: getProxyTarget(req, true), + target: getProxyTarget(req, req.args["proxy-path-passthrough"] || false), }) }) From 497b01bffe176453ffc25cf83854180377d8077e Mon Sep 17 00:00:00 2001 From: Anmol Sethi Date: Mon, 11 Jan 2021 13:18:07 -0500 Subject: [PATCH 02/12] FAQ.md: Document --proxy-path-passthrough And the concerns surrounding it. Closes #2485 --- doc/FAQ.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/doc/FAQ.md b/doc/FAQ.md index f3c58db8..19a4ff37 100644 --- a/doc/FAQ.md +++ b/doc/FAQ.md @@ -15,6 +15,7 @@ - [How do I securely access web services?](#how-do-i-securely-access-web-services) - [Sub-paths](#sub-paths) - [Sub-domains](#sub-domains) +- [Why does the code-server proxy strip `/proxy/` from the request path?](#why-does-the-code-server-proxy-strip-proxyport-from-the-request-path) - [Multi-tenancy](#multi-tenancy) - [Docker in code-server container?](#docker-in-code-server-container) - [How can I disable telemetry?](#how-can-i-disable-telemetry) @@ -208,6 +209,36 @@ code-server --proxy-domain Now you can browse to `.`. Note that this uses the host header so ensure your reverse proxy forwards that information if you are using one. +## Why does the code-server proxy strip `/proxy/` from the request path? + +HTTP servers should strive to use relative URLs to avoid needed to be coupled to the +absolute path at which they are served. This means you must use trailing slashes on all +paths with subpaths. See https://blog.cdivilly.com/2019/02/28/uri-trailing-slashes + +This is really the "correct" way things work and why the striping of the base path is the +default. If your application uses relative URLs and does not assume the absolute path at +which it is being served, it will just work no matter what port you decide to serve it off +or if you put it in behind code-server or any other proxy! + +However many people prefer the cleaner aesthetic of no trailing slashes. This couples you +to the base path as you cannot use relative redirects correctly anymore. See the above +link. + +For users who are ok with this tradeoff, pass `--proxy-path-passthrough` to code-server +and the path will be passed as is. + +This is particularly a problem with the `start` script in create-react-app. See +[#2222](https://github.com/cdr/code-server/issues/2222). You will need to inform +create-react-app of the path at which you are serving via `homepage` field in your +`package.json`. e.g. you'd add the following for the default CRA port: + +```json + "homepage": "/proxy/3000", +``` + +Then visit `https://my-code-server-address.io/proxy/3000` to see your app exposed through +code-server! + ## Multi-tenancy If you want to run multiple code-servers on shared infrastructure, we recommend using virtual From ba4a24809c6273a3c449c1608193e3e1523bcedc Mon Sep 17 00:00:00 2001 From: Anmol Sethi Date: Mon, 11 Jan 2021 14:33:46 -0500 Subject: [PATCH 03/12] routes/index.ts: Correctly register wsErrorHandler express requires all 4 arguments to be declared for a error handler. It's very unfortunate that our types do not handle this. --- src/node/routes/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/node/routes/index.ts b/src/node/routes/index.ts index a7b7c185..4688d6a2 100644 --- a/src/node/routes/index.ts +++ b/src/node/routes/index.ts @@ -165,7 +165,7 @@ export const register = async ( app.use(errorHandler) - const wsErrorHandler: express.ErrorRequestHandler = async (err, req) => { + const wsErrorHandler: express.ErrorRequestHandler = async (err, req, res, next) => { logger.error(`${err.message} ${err.stack}`) ;(req as WebsocketRequest).ws.end() } From 9efcf7f3ce62c90fa7ed52d1a9c5e000c10091cb Mon Sep 17 00:00:00 2001 From: Anmol Sethi Date: Mon, 11 Jan 2021 14:36:34 -0500 Subject: [PATCH 04/12] FAQ.md: Document wds problem with create-react-app and pathProxy.ts --- doc/FAQ.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/FAQ.md b/doc/FAQ.md index 19a4ff37..5d14a312 100644 --- a/doc/FAQ.md +++ b/doc/FAQ.md @@ -239,6 +239,12 @@ create-react-app of the path at which you are serving via `homepage` field in yo Then visit `https://my-code-server-address.io/proxy/3000` to see your app exposed through code-server! +Unfortunately `webpack-dev-server`'s websocket connections will not go through as it +always uses `/sockjs-node`. So hot reloading will not work until the `create-react-app` +team fix this bug. + +Highly recommend using the subdomain approach instead to avoid this class of issue. + ## Multi-tenancy If you want to run multiple code-servers on shared infrastructure, we recommend using virtual From ea1949e4406ed4ecaeacdf4b45ee8fb8e54f2747 Mon Sep 17 00:00:00 2001 From: Anmol Sethi Date: Thu, 14 Jan 2021 09:34:51 -0500 Subject: [PATCH 05/12] test: Add testutil.HttpServer The goal is to remove supertest as it does not support typescript well and there's really no good reason for the dependency. Also no websocket testing support. --- package.json | 4 +-- test/testutil.ts | 65 ++++++++++++++++++++++++++++++++++++ yarn.lock | 87 +++++++++--------------------------------------- 3 files changed, 82 insertions(+), 74 deletions(-) create mode 100644 test/testutil.ts diff --git a/package.json b/package.json index aab6a111..f3e368c3 100644 --- a/package.json +++ b/package.json @@ -38,13 +38,13 @@ "@types/js-yaml": "^3.12.3", "@types/mocha": "^8.0.3", "@types/node": "^12.12.7", + "@types/node-fetch": "^2.5.7", "@types/parcel-bundler": "^1.12.1", "@types/pem": "^1.9.5", "@types/proxy-from-env": "^1.0.1", "@types/safe-compare": "^1.1.0", "@types/semver": "^7.1.0", "@types/split2": "^2.1.6", - "@types/supertest": "^2.0.10", "@types/tar-fs": "^2.0.0", "@types/tar-stream": "^2.1.0", "@types/ws": "^7.2.6", @@ -61,7 +61,6 @@ "prettier": "^2.0.5", "stylelint": "^13.0.0", "stylelint-config-recommended": "^3.0.0", - "supertest": "^6.0.1", "ts-node": "^9.0.0", "typescript": "4.0.2" }, @@ -81,6 +80,7 @@ "httpolyglot": "^0.1.2", "js-yaml": "^3.13.1", "limiter": "^1.1.5", + "node-fetch": "^2.6.1", "pem": "^1.14.2", "proxy-agent": "^4.0.0", "proxy-from-env": "^1.1.0", diff --git a/test/testutil.ts b/test/testutil.ts new file mode 100644 index 00000000..56166275 --- /dev/null +++ b/test/testutil.ts @@ -0,0 +1,65 @@ +import * as http from "http" +import { logger } from "@coder/logger" +import { ensureAddress } from "../src/node/app" +import * as nodeFetch from "node-fetch" + +export class HttpServer { + private hs = http.createServer() + + /** + * listen starts the server on a random localhost port. + * Use close to cleanup when done. + */ + public listen(fn: http.RequestListener): Promise { + this.hs.on("request", fn) + + let resolved = false + return new Promise((res, rej) => { + this.hs.listen(0, "localhost", () => { + res() + resolved = true + }) + + this.hs.on("error", (err) => { + if (!resolved) { + rej(err) + } else { + // Promise resolved earlier so this is some other error. + logError("server error", err) + } + }) + }) + } + + /** + * close cleans up the server. + */ + public close(): Promise { + return new Promise((res, rej) => { + this.hs.close((err) => { + if (err) { + rej(err) + return + } + res() + }) + }) + } + + /** + * fetch fetches the request path. + * The request path must be rooted! + */ + public fetch(requestPath: string, opts?: nodeFetch.RequestInit): Promise { + return nodeFetch.default(`${ensureAddress(this.hs)}${requestPath}`, opts) + } +} + + +export function logError(prefix: string, err: any): void { + if (err instanceof Error) { + logger.error(`${prefix}: ${err.message} ${err.stack}`) + } else { + logger.error(`${prefix}: ${err}`) + } +} diff --git a/yarn.lock b/yarn.lock index e0e3fa62..afbef22c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1040,11 +1040,6 @@ dependencies: "@types/express" "*" -"@types/cookiejar@*": - version "2.1.2" - resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.2.tgz#66ad9331f63fe8a3d3d9d8c6e3906dd10f6446e8" - integrity sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog== - "@types/express-serve-static-core@*": version "4.17.13" resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.13.tgz#d9af025e925fc8b089be37423b8d1eac781be084" @@ -1108,6 +1103,14 @@ resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-8.0.3.tgz#51b21b6acb6d1b923bbdc7725c38f9f455166402" integrity sha512-vyxR57nv8NfcU0GZu8EUXZLTbCMupIUwy95LJ6lllN+JRPG25CwMHoB1q5xKh8YKhQnHYRAn4yW2yuHbf/5xgg== +"@types/node-fetch@^2.5.7": + version "2.5.7" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.5.7.tgz#20a2afffa882ab04d44ca786449a276f9f6bbf3c" + integrity sha512-o2WVNf5UhWRkxlf6eq+jMZDu7kjgpgJfl4xVNlvryc95O/6F2ld8ztKX+qu+Rjyet93WAWm5LjeX9H5FGkODvw== + dependencies: + "@types/node" "*" + form-data "^3.0.0" + "@types/node@*", "@types/node@^12.12.7": version "12.12.67" resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.67.tgz#4f86badb292e822e3b13730a1f9713ed2377f789" @@ -1184,21 +1187,6 @@ dependencies: "@types/node" "*" -"@types/superagent@*": - version "4.1.10" - resolved "https://registry.yarnpkg.com/@types/superagent/-/superagent-4.1.10.tgz#5e2cc721edf58f64fe9b819f326ee74803adee86" - integrity sha512-xAgkb2CMWUMCyVc/3+7iQfOEBE75NvuZeezvmixbUw3nmENf2tCnQkW5yQLTYqvXUQ+R6EXxdqKKbal2zM5V/g== - dependencies: - "@types/cookiejar" "*" - "@types/node" "*" - -"@types/supertest@^2.0.10": - version "2.0.10" - resolved "https://registry.yarnpkg.com/@types/supertest/-/supertest-2.0.10.tgz#630d79b4d82c73e043e43ff777a9ca98d457cab7" - integrity sha512-Xt8TbEyZTnD5Xulw95GLMOkmjGICrOQyJ2jqgkSjAUR3mm7pAIzSR0NFBaMcwlzVvlpCjNwbATcWWwjNiZiFrQ== - dependencies: - "@types/superagent" "*" - "@types/tar-fs@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@types/tar-fs/-/tar-fs-2.0.0.tgz#db94cb4ea1cccecafe3d1a53812807efb4bbdbc1" @@ -2255,7 +2243,7 @@ commander@^5.0.0: resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== -component-emitter@^1.2.1, component-emitter@^1.3.0: +component-emitter@^1.2.1: version "1.3.0" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== @@ -2327,11 +2315,6 @@ cookie@0.4.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba" integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg== -cookiejar@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.2.tgz#dd8a235530752f988f9a0844f3fc589e3111125c" - integrity sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA== - copy-descriptor@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" @@ -3428,11 +3411,6 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= -fast-safe-stringify@^2.0.7: - version "2.0.7" - resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz#124aa885899261f68aedb42a7c080de9da608743" - integrity sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA== - fastest-levenshtein@^1.0.12: version "1.0.12" resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.12.tgz#9990f7d3a88cc5a9ffd1f1745745251700d497e2" @@ -3603,11 +3581,6 @@ format@^0.2.0: resolved "https://registry.yarnpkg.com/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b" integrity sha1-1hcBB+nv3E7TDJ3DkBbflCtctYs= -formidable@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.2.2.tgz#bf69aea2972982675f00865342b982986f6b8dd9" - integrity sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q== - forwarded@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84" @@ -4978,7 +4951,7 @@ merge2@^1.2.3, merge2@^1.3.0: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -methods@1.1.2, methods@^1.1.2, methods@~1.1.2: +methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= @@ -5035,11 +5008,6 @@ mime@1.6.0: resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== -mime@^2.4.6: - version "2.4.6" - resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.6.tgz#e5b407c90db442f2beb5b162373d07b69affa4d1" - integrity sha512-RZKhC3EmpBchfTGBVb8fb+RL2cWyw/32lshnsETttkBAyAUXSGHxbEJWWRXc751DrIxG1q04b8QwMbAwkRPpUA== - mimic-fn@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" @@ -5214,6 +5182,11 @@ node-addon-api@^1.7.1: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-1.7.2.tgz#3df30b95720b53c24e59948b49532b662444f54d" integrity sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg== +node-fetch@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== + node-forge@^0.7.1: version "0.7.6" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.6.tgz#fdf3b418aee1f94f0ef642cd63486c77ca9724ac" @@ -6405,11 +6378,6 @@ qs@6.7.0: resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== -qs@^6.9.4: - version "6.9.4" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.4.tgz#9090b290d1f91728d3c22e54843ca44aea5ab687" - integrity sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ== - qs@~6.5.2: version "6.5.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" @@ -7522,31 +7490,6 @@ sugarss@^2.0.0: dependencies: postcss "^7.0.2" -superagent@6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/superagent/-/superagent-6.1.0.tgz#09f08807bc41108ef164cfb4be293cebd480f4a6" - integrity sha512-OUDHEssirmplo3F+1HWKUrUjvnQuA+nZI6i/JJBdXb5eq9IyEQwPyPpqND+SSsxf6TygpBEkUjISVRN4/VOpeg== - dependencies: - component-emitter "^1.3.0" - cookiejar "^2.1.2" - debug "^4.1.1" - fast-safe-stringify "^2.0.7" - form-data "^3.0.0" - formidable "^1.2.2" - methods "^1.1.2" - mime "^2.4.6" - qs "^6.9.4" - readable-stream "^3.6.0" - semver "^7.3.2" - -supertest@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/supertest/-/supertest-6.0.1.tgz#f6b54370de85c45d6557192c8d7df604ca2c9e18" - integrity sha512-8yDNdm+bbAN/jeDdXsRipbq9qMpVF7wRsbwLgsANHqdjPsCoecmlTuqEcLQMGpmojFBhxayZ0ckXmLXYq7e+0g== - dependencies: - methods "1.1.2" - superagent "6.1.0" - supports-color@7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1" From 8acb2aec113edfce0e9734dd463b361a6d00be20 Mon Sep 17 00:00:00 2001 From: Anmol Sethi Date: Thu, 14 Jan 2021 09:37:41 -0500 Subject: [PATCH 06/12] plugin.test.ts: Switch to testutil.HttpServer --- test/plugin.test.ts | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 305cf041..167804bb 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -1,11 +1,12 @@ import { logger } from "@coder/logger" +import * as assert from "assert" import * as express from "express" import * as fs from "fs" import { describe } from "mocha" import * as path from "path" -import * as supertest from "supertest" import { PluginAPI } from "../src/node/plugin" import * as apps from "../src/node/routes/apps" +import * as testutil from "./testutil" const fsp = fs.promises /** @@ -13,23 +14,30 @@ const fsp = fs.promises */ describe("plugin", () => { let papi: PluginAPI - let app: express.Application - let agent: supertest.SuperAgentTest + let s: testutil.HttpServer before(async () => { - papi = new PluginAPI(logger, path.resolve(__dirname, "test-plugin") + ":meow") + papi = new PluginAPI(logger, `${path.resolve(__dirname, "test-plugin")}:meow`) await papi.loadPlugins() - app = express.default() + const app = express.default() papi.mount(app) - app.use("/api/applications", apps.router(papi)) - agent = supertest.agent(app) + s = new testutil.HttpServer() + await s.listen(app) + }) + + after(async () => { + await s.close() }) it("/api/applications", async () => { - await agent.get("/api/applications").expect(200, [ + const resp = await s.fetch("/api/applications") + assert.equal(200, resp.status) + const body = await resp.json() + logger.debug(`${JSON.stringify(body)}`) + assert.deepEqual(body, [ { name: "Test App", version: "4.0.0", @@ -57,6 +65,9 @@ describe("plugin", () => { const indexHTML = await fsp.readFile(path.join(__dirname, "test-plugin/public/index.html"), { encoding: "utf8", }) - await agent.get("/test-plugin/test-app").expect(200, indexHTML) + const resp = await s.fetch("/test-plugin/test-app") + assert.equal(200, resp.status) + const body = await resp.text() + assert.equal(body, indexHTML) }) }) From d3074278ca26102650e728fd4102b5b1cc330997 Mon Sep 17 00:00:00 2001 From: Anmol Sethi Date: Thu, 14 Jan 2021 09:49:23 -0500 Subject: [PATCH 07/12] app.ts: Fix createApp to log all http server errors cc @code-asher --- src/common/util.ts | 8 ++++++++ src/node/app.ts | 18 ++++++++++++++++-- test/testutil.ts | 15 +++------------ 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/common/util.ts b/src/common/util.ts index b4f66be2..87ca6f59 100644 --- a/src/common/util.ts +++ b/src/common/util.ts @@ -112,3 +112,11 @@ export const getFirstString = (value: string | string[] | object | undefined): s return typeof value === "string" ? value : undefined } + +export function logError(prefix: string, err: any): void { + if (err instanceof Error) { + logger.error(`${prefix}: ${err.message} ${err.stack}`) + } else { + logger.error(`${prefix}: ${err}`) + } +} diff --git a/src/node/app.ts b/src/node/app.ts index 448ec966..1d9ce9d4 100644 --- a/src/node/app.ts +++ b/src/node/app.ts @@ -3,6 +3,7 @@ import express, { Express } from "express" import { promises as fs } from "fs" import http from "http" import * as httpolyglot from "httpolyglot" +import * as util from "../common/util" import { DefaultedArgs } from "./cli" import { handleUpgrade } from "./wsRouter" @@ -22,8 +23,21 @@ export const createApp = async (args: DefaultedArgs): Promise<[Express, Express, ) : http.createServer(app) - await new Promise(async (resolve, reject) => { - server.on("error", reject) + let resolved = false + await new Promise(async (resolve2, reject) => { + const resolve = () => { + resolved = true + resolve2() + } + server.on("error", (err) => { + if (!resolved) { + reject(err) + } else { + // Promise resolved earlier so this is an unrelated error. + util.logError("http server error", err) + } + }) + if (args.socket) { try { await fs.unlink(args.socket) diff --git a/test/testutil.ts b/test/testutil.ts index 56166275..dc88da3d 100644 --- a/test/testutil.ts +++ b/test/testutil.ts @@ -1,7 +1,7 @@ import * as http from "http" -import { logger } from "@coder/logger" -import { ensureAddress } from "../src/node/app" import * as nodeFetch from "node-fetch" +import * as util from "../src/common/util" +import { ensureAddress } from "../src/node/app" export class HttpServer { private hs = http.createServer() @@ -25,7 +25,7 @@ export class HttpServer { rej(err) } else { // Promise resolved earlier so this is some other error. - logError("server error", err) + util.logError("http server error", err) } }) }) @@ -54,12 +54,3 @@ export class HttpServer { return nodeFetch.default(`${ensureAddress(this.hs)}${requestPath}`, opts) } } - - -export function logError(prefix: string, err: any): void { - if (err instanceof Error) { - logger.error(`${prefix}: ${err.message} ${err.stack}`) - } else { - logger.error(`${prefix}: ${err}`) - } -} From 64e915de4a8431932da71c3530dbfefdfa14347b Mon Sep 17 00:00:00 2001 From: Anmol Sethi Date: Thu, 14 Jan 2021 09:53:34 -0500 Subject: [PATCH 08/12] test: Rename testutil.ts to httpserver.ts --- test/{testutil.ts => httpserver.ts} | 0 test/plugin.test.ts | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) rename test/{testutil.ts => httpserver.ts} (100%) diff --git a/test/testutil.ts b/test/httpserver.ts similarity index 100% rename from test/testutil.ts rename to test/httpserver.ts diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 167804bb..1259a91e 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -6,7 +6,7 @@ import { describe } from "mocha" import * as path from "path" import { PluginAPI } from "../src/node/plugin" import * as apps from "../src/node/routes/apps" -import * as testutil from "./testutil" +import * as httpserver from "./httpserver" const fsp = fs.promises /** @@ -14,7 +14,7 @@ const fsp = fs.promises */ describe("plugin", () => { let papi: PluginAPI - let s: testutil.HttpServer + let s: httpserver.HttpServer before(async () => { papi = new PluginAPI(logger, `${path.resolve(__dirname, "test-plugin")}:meow`) @@ -24,7 +24,7 @@ describe("plugin", () => { papi.mount(app) app.use("/api/applications", apps.router(papi)) - s = new testutil.HttpServer() + s = new httpserver.HttpServer() await s.listen(app) }) From 240c8e266ed0e18517e5c98e0ab270ce63b6e446 Mon Sep 17 00:00:00 2001 From: Anmol Sethi Date: Thu, 14 Jan 2021 10:53:58 -0500 Subject: [PATCH 09/12] test: Implement integration.ts for near full stack integration testing --- src/node/cli.ts | 16 ++++++++++++++-- test/httpserver.ts | 16 ++++++++++++++++ test/integration.ts | 16 ++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 test/integration.ts diff --git a/src/node/cli.ts b/src/node/cli.ts index 5a0e4473..2469bedb 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -244,7 +244,7 @@ export const optionDescriptions = (): string[] => { export const parse = ( argv: string[], opts?: { - configFile: string + configFile?: string }, ): Args => { const error = (msg: string): Error => { @@ -521,7 +521,19 @@ export async function readConfigFile(configPath?: string): Promise { } const configFile = await fs.readFile(configPath) - const config = yaml.safeLoad(configFile.toString(), { + return parseConfigFile(configFile.toString(), configPath) +} + +/** + * parseConfigFile parses configFile into ConfigArgs. + * configPath is used as the filename in error messages + */ +export function parseConfigFile(configFile: string, configPath: string): ConfigArgs { + if (configFile == "") { + return { _: [], config: configPath } + } + + const config = yaml.safeLoad(configFile, { filename: configPath, }) if (!config || typeof config === "string") { diff --git a/test/httpserver.ts b/test/httpserver.ts index dc88da3d..570adf13 100644 --- a/test/httpserver.ts +++ b/test/httpserver.ts @@ -3,9 +3,17 @@ import * as nodeFetch from "node-fetch" import * as util from "../src/common/util" import { ensureAddress } from "../src/node/app" +// Perhaps an abstraction similar to this should be used in app.ts as well. export class HttpServer { private hs = http.createServer() + public constructor(hs?: http.Server) { + // See usage in test/integration.ts + if (hs) { + this.hs = hs + } + } + /** * listen starts the server on a random localhost port. * Use close to cleanup when done. @@ -53,4 +61,12 @@ export class HttpServer { public fetch(requestPath: string, opts?: nodeFetch.RequestInit): Promise { return nodeFetch.default(`${ensureAddress(this.hs)}${requestPath}`, opts) } + + public port(): number { + const addr = this.hs.address() + if (addr && typeof addr == "object") { + return addr.port + } + throw new Error("server not listening or listening on unix socket") + } } diff --git a/test/integration.ts b/test/integration.ts new file mode 100644 index 00000000..a4cc7586 --- /dev/null +++ b/test/integration.ts @@ -0,0 +1,16 @@ +import { createApp } from "../src/node/app" +import { register } from "../src/node/routes" +import { parse, setDefaults, parseConfigFile, DefaultedArgs } from "../src/node/cli" +import * as httpserver from "./httpserver" +import * as express from "express" + +export async function setup(argv: string[], configFile?: string): Promise<[express.Application, express.Application, httpserver.HttpServer, DefaultedArgs]> { + const cliArgs = parse(argv) + let configArgs = parseConfigFile(configFile || "", "test/integration.ts") + const args = await setDefaults(cliArgs, configArgs) + + const [app, wsApp, server] = await createApp(args) + await register(app, wsApp, server, args) + + return [app, wsApp, new httpserver.HttpServer(server), args] +} From 60233d09746b5e8792bc94440d0d0eddf3b69abf Mon Sep 17 00:00:00 2001 From: Anmol Sethi Date: Thu, 14 Jan 2021 10:54:34 -0500 Subject: [PATCH 10/12] test/proxy.test.ts: Implement --- test/proxy.test.ts | 47 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 test/proxy.test.ts diff --git a/test/proxy.test.ts b/test/proxy.test.ts new file mode 100644 index 00000000..defb9916 --- /dev/null +++ b/test/proxy.test.ts @@ -0,0 +1,47 @@ +import * as integration from "./integration" +import * as httpserver from "./httpserver" +import * as express from "express" +import * as assert from "assert" + +describe("proxy", () => { + let codeServer: httpserver.HttpServer | undefined + let nhooyrDevServer = new httpserver.HttpServer() + let proxyPath: string + + before(async () => { + const e = express.default() + await nhooyrDevServer.listen(e) + e.get("/wsup", (req, res) => { + res.json("asher is the best") + }) + proxyPath = `/proxy/${nhooyrDevServer.port()}/wsup` + e.get(proxyPath, (req, res) => { + res.json("joe is the best") + }) + }) + + after(async () => { + await nhooyrDevServer.close() + }) + + afterEach(async () => { + if (codeServer) { + await codeServer.close() + codeServer = undefined + } + }) + + it("should rewrite the base path", async () => { + ;[,, codeServer,] = await integration.setup(["--auth=none"], "") + const resp = await codeServer.fetch(proxyPath) + assert.equal(resp.status, 200) + assert.equal(await resp.json(), "asher is the best") + }) + + it("should not rewrite the base path", async () => { + ;[,,codeServer,] = await integration.setup(["--auth=none", "--proxy-path-passthrough=true"], "") + const resp = await codeServer.fetch(proxyPath) + assert.equal(resp.status, 200) + assert.equal(await resp.json(), "joe is the best") + }) +}) From 5c06646f5804603ee6ef7104ac858872a3fbda35 Mon Sep 17 00:00:00 2001 From: Anmol Sethi Date: Thu, 14 Jan 2021 10:55:19 -0500 Subject: [PATCH 11/12] Formatting and linting fixes --- src/node/cli.ts | 4 ++-- test/httpserver.ts | 2 +- test/integration.ts | 15 +++++++++------ test/proxy.test.ts | 12 ++++++------ 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/node/cli.ts b/src/node/cli.ts index 2469bedb..c8ca387a 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -529,8 +529,8 @@ export async function readConfigFile(configPath?: string): Promise { * configPath is used as the filename in error messages */ export function parseConfigFile(configFile: string, configPath: string): ConfigArgs { - if (configFile == "") { - return { _: [], config: configPath } + if (!configFile) { + return { _: [], config: configPath } } const config = yaml.safeLoad(configFile, { diff --git a/test/httpserver.ts b/test/httpserver.ts index 570adf13..50f88786 100644 --- a/test/httpserver.ts +++ b/test/httpserver.ts @@ -64,7 +64,7 @@ export class HttpServer { public port(): number { const addr = this.hs.address() - if (addr && typeof addr == "object") { + if (addr && typeof addr === "object") { return addr.port } throw new Error("server not listening or listening on unix socket") diff --git a/test/integration.ts b/test/integration.ts index a4cc7586..6a321cae 100644 --- a/test/integration.ts +++ b/test/integration.ts @@ -1,12 +1,15 @@ -import { createApp } from "../src/node/app" -import { register } from "../src/node/routes" -import { parse, setDefaults, parseConfigFile, DefaultedArgs } from "../src/node/cli" -import * as httpserver from "./httpserver" import * as express from "express" +import { createApp } from "../src/node/app" +import { parse, setDefaults, parseConfigFile, DefaultedArgs } from "../src/node/cli" +import { register } from "../src/node/routes" +import * as httpserver from "./httpserver" -export async function setup(argv: string[], configFile?: string): Promise<[express.Application, express.Application, httpserver.HttpServer, DefaultedArgs]> { +export async function setup( + argv: string[], + configFile?: string, +): Promise<[express.Application, express.Application, httpserver.HttpServer, DefaultedArgs]> { const cliArgs = parse(argv) - let configArgs = parseConfigFile(configFile || "", "test/integration.ts") + const configArgs = parseConfigFile(configFile || "", "test/integration.ts") const args = await setDefaults(cliArgs, configArgs) const [app, wsApp, server] = await createApp(args) diff --git a/test/proxy.test.ts b/test/proxy.test.ts index defb9916..88948fa1 100644 --- a/test/proxy.test.ts +++ b/test/proxy.test.ts @@ -1,11 +1,11 @@ -import * as integration from "./integration" -import * as httpserver from "./httpserver" -import * as express from "express" import * as assert from "assert" +import * as express from "express" +import * as httpserver from "./httpserver" +import * as integration from "./integration" describe("proxy", () => { let codeServer: httpserver.HttpServer | undefined - let nhooyrDevServer = new httpserver.HttpServer() + const nhooyrDevServer = new httpserver.HttpServer() let proxyPath: string before(async () => { @@ -32,14 +32,14 @@ describe("proxy", () => { }) it("should rewrite the base path", async () => { - ;[,, codeServer,] = await integration.setup(["--auth=none"], "") + ;[, , codeServer] = await integration.setup(["--auth=none"], "") const resp = await codeServer.fetch(proxyPath) assert.equal(resp.status, 200) assert.equal(await resp.json(), "asher is the best") }) it("should not rewrite the base path", async () => { - ;[,,codeServer,] = await integration.setup(["--auth=none", "--proxy-path-passthrough=true"], "") + ;[, , codeServer] = await integration.setup(["--auth=none", "--proxy-path-passthrough=true"], "") const resp = await codeServer.fetch(proxyPath) assert.equal(resp.status, 200) assert.equal(await resp.json(), "joe is the best") From c32d8b155ff0395ccee10275b88dec935736e4ea Mon Sep 17 00:00:00 2001 From: Anmol Sethi Date: Mon, 18 Jan 2021 08:30:00 -0500 Subject: [PATCH 12/12] heart.ts: Fix leak when server closes This had me very confused for quite a while until I did a binary search inspection on route/index.ts. Only with the heart.beat line commented out did my tests pass without leaking. They weren't leaking fds but just this heartbeat timer and node of course prints just fds that are active when it detects some sort of leak I guess and that made the whole thing very confusing. These fds are not leaked and will close when node's event loop detects there are no more callbacks to run. no of handles 3 tcp stream { fd: 20, readable: false, writable: true, address: {}, serverAddr: null } tcp stream { fd: 22, readable: false, writable: true, address: {}, serverAddr: null } tcp stream { fd: 23, readable: true, writable: false, address: {}, serverAddr: null } It kept printing the above text again and again for 60s and then the test binary times out I think. I'm not sure if it was node printing the stuff above or if it was a mocha thing. But it was really confusing... cc @code-asher for thoughts on what was going on. edit: It was the leaked-handles import in socket.test.ts!!! Not sure if we should keep it, this was really confusing and misleading. --- src/node/heart.ts | 9 +++++++++ src/node/routes/index.ts | 3 +++ test/integration.ts | 2 ++ 3 files changed, 14 insertions(+) diff --git a/src/node/heart.ts b/src/node/heart.ts index eed070e4..1eada79b 100644 --- a/src/node/heart.ts +++ b/src/node/heart.ts @@ -45,4 +45,13 @@ export class Heart { }) }, this.heartbeatInterval) } + + /** + * Call to clear any heartbeatTimer for shutdown. + */ + public dispose(): void { + if (typeof this.heartbeatTimer !== "undefined") { + clearTimeout(this.heartbeatTimer) + } + } } diff --git a/src/node/routes/index.ts b/src/node/routes/index.ts index 4688d6a2..00654367 100644 --- a/src/node/routes/index.ts +++ b/src/node/routes/index.ts @@ -55,6 +55,9 @@ export const register = async ( }) }) }) + server.on("close", () => { + heart.dispose() + }) app.disable("x-powered-by") wsApp.disable("x-powered-by") diff --git a/test/integration.ts b/test/integration.ts index 6a321cae..f829fe5d 100644 --- a/test/integration.ts +++ b/test/integration.ts @@ -8,6 +8,8 @@ export async function setup( argv: string[], configFile?: string, ): Promise<[express.Application, express.Application, httpserver.HttpServer, DefaultedArgs]> { + argv = ["--bind-addr=localhost:0", ...argv] + const cliArgs = parse(argv) const configArgs = parseConfigFile(configFile || "", "test/integration.ts") const args = await setDefaults(cliArgs, configArgs)