Compare commits

..

44 Commits
update ... qol

Author SHA1 Message Date
Asher
d8568ebaa9 Enforce import order 2020-08-13 17:11:35 -05:00
Asher
f7790c9719 Remove unused deep merge code 2020-08-13 17:11:34 -05:00
Asher
150d37868a Enforce strict equals 2020-08-13 17:11:33 -05:00
Asher
8590f80c31 Remove unnecessary tsc settings 2020-08-13 17:11:33 -05:00
Asher
d6d24966be Move vscode JS to a separate file
Mostly to match everything else.
2020-08-13 17:10:59 -05:00
Asher
751a5ea3ad Move login JS to a separate file
Mostly so the base URL resolution code can be shared.
2020-08-13 17:10:54 -05:00
Asher
de568d446b Add cookie domain debug logs
To help debug login issues.
2020-08-13 17:06:32 -05:00
Asher
7d02f34f71 Merge pull request #1934 from cdr/plugin
Add plugin system for adding http endpoints
2020-08-13 16:59:44 -05:00
G r e y
2fad8a2a58 Merge pull request #1955 from cdr/callback-type
Add Callback type
2020-08-11 00:41:28 -04:00
G r e y
a0ff2014c3 Add Callback type
Adds a reusable Callback type that is applied to emitter.ts for improved
readability/simplicity.
2020-08-10 21:41:46 -05:00
G r e y
8d03c22cb0 Merge pull request #1956 from cdr/plural
Update common/util::plural
2020-08-10 17:44:06 -04:00
G r e y
6e27869c09 Add str param to plural util
Adds a str param to common/util::plural for pluralizing a string.
Applies plural to entry.ts.
2020-08-09 00:06:18 -05:00
Asher
934c8d4eb6 Clarify exported types and ipc.d.ts 2020-08-05 13:00:37 -05:00
Asher
9b979ac869 Document code-server injection 2020-08-05 13:00:37 -05:00
Asher
3badf6bf7b Use ?? for base default 2020-08-05 13:00:36 -05:00
Asher
10c2b956ac Remove leading slash trim in base resolver
It's not necessary since we return early if the path starts with a
slash.
2020-08-05 13:00:35 -05:00
Asher
543d64268d Simplify valid path check 2020-08-05 13:00:34 -05:00
Asher
fd36f8c168 Use error log level for plugin load failure 2020-08-05 13:00:33 -05:00
G r e y
c78d164948 Fix nfpm typo (#1943) 2020-08-05 12:48:41 -04:00
Anmol Sethi
4dd2c86cca FAQ: Demonstrate how to switch the marketplace 2020-08-04 10:11:55 -04:00
Asher
42467b3e66 Watch plugin and restart when it changes 2020-07-31 17:42:49 -05:00
Asher
361e7103ea Enable loading external plugins 2020-07-31 17:42:48 -05:00
Asher
bac948ea6f Add plugin system 2020-07-31 15:08:02 -05:00
Asher
1c8eede1aa Add missing types to release
code-server exports its types but they weren't complete since it imports
ipc.d.ts and that wasn't being included.
2020-07-31 14:08:00 -05:00
Asher
486652abaf Update standalone test to account for timestamp
The updated logger outputs timestamps now.
2020-07-31 14:06:49 -05:00
Asher
5370f7876d Merge pull request #1927 from cdr/dead-code
Remove dead code
2020-07-31 12:25:56 -05:00
Asher
eccaf8eb50 Merge pull request #1931 from cdr/rimraf
Fix package step
2020-07-31 12:25:18 -05:00
Asher
cbf7c9556c Merge pull request #1920 from fxxjdedd/patch-1
feat: persist route query to local
2020-07-31 11:36:25 -05:00
Asher
b63cf192b5 Remove broken symlinks in extensions node modules
The broken symlinks cause nfpm to fail.
2020-07-31 10:49:45 -05:00
Asher
50ed29e0f0 Move rimraf to prod deps in extensions
The postinstall uses rimraf so it needs to exist in the final build.
2020-07-31 10:49:40 -05:00
futengda
ecb9bb2428 refactor: write lastVisited and query at the same time
In addition, the `settings.write` method now uses shallow merge by default
2020-07-31 12:25:20 +08:00
Asher
e86c066438 Add helper functions to make some code clearer 2020-07-30 12:14:31 -05:00
futengda
b6e791f7d0 refactor: write route.query via settings.write
I added a shallow parameter, because the query should not be extends, but should be replaced directly.
2020-07-30 16:54:02 +08:00
Asher
c581bca29d Force minimist update 2020-07-29 18:48:08 -05:00
Asher
2fa5037859 Log output to disk 2020-07-29 18:48:07 -05:00
Asher
7c2ca7d03e Add the ability to prepend to the proxy path
This is for applications like Jupyter that aren't base path agnostic.
2020-07-29 18:48:06 -05:00
Asher
c67d31580f Include details if any in JSON requests 2020-07-29 18:48:05 -05:00
Asher
58bd7008b4 Make dispose async 2020-07-29 18:48:04 -05:00
Asher
4b6c0a6fc3 Update logger 2020-07-29 18:48:03 -05:00
Asher
554b6d6fcf Remove apply portion of update endpoint
It can still be used to check for updates but will not apply them.

For now also remove the update check loop in VS Code since it's
currently unused (update check is hardcoded off right now) and won't
work anyway since it also applies the update which now won't work. In
the future we should integrate the check into the browser update
service.
2020-07-29 18:48:02 -05:00
jae
8021385ac4 Add enterprise context (#1923) 2020-07-29 13:37:19 -04:00
fxxjdedd
5ba650bb6f feat: persist route query to local
Provide a way for the shell script running in the docker container to get the url query.
2020-07-28 20:14:52 +08:00
Asher
e8f6d30055 Make providers endpoint-agnostic
A provider can now be registered on multiple endpoints (or potentially
moved if needed).
2020-07-27 12:00:48 -05:00
Asher
2819fd51e2 Remove unused endpoints
- dashboard
- app api
2020-07-27 12:00:42 -05:00
48 changed files with 1544 additions and 2488 deletions

View File

@@ -23,6 +23,9 @@ rules:
no-dupe-class-members: off no-dupe-class-members: off
"@typescript-eslint/no-use-before-define": off "@typescript-eslint/no-use-before-define": off
"@typescript-eslint/no-non-null-assertion": off "@typescript-eslint/no-non-null-assertion": off
eqeqeq: error
import/order:
[error, { alphabetize: { order: "asc" }, groups: [["builtin", "external", "internal"], "parent", "sibling"] }]
settings: settings:
# Does not work with CommonJS unfortunately. # Does not work with CommonJS unfortunately.

1
.gitignore vendored
View File

@@ -10,3 +10,4 @@ release-gcp/
release-images/ release-images/
node_modules node_modules
node-* node-*
/plugins

View File

@@ -60,7 +60,6 @@ you're in North America or Europe.
Please get in [touch](mailto:jobs@coder.com) with your resume/github if interested. Please get in [touch](mailto:jobs@coder.com) with your resume/github if interested.
## Enterprise ## For Organizations
Visit [our website](https://coder.com) for more information about our Visit [our website](https://coder.com) for more information about remote development for your organization or enterprise.
enterprise offerings.

View File

@@ -9,7 +9,8 @@ MINIFY=${MINIFY-true}
main() { main() {
cd "$(dirname "${0}")/../.." cd "$(dirname "${0}")/../.."
tsc --outDir out --tsBuildInfoFile .cache/out.tsbuildinfo tsc
# If out/node/entry.js does not already have the shebang, # If out/node/entry.js does not already have the shebang,
# we make sure to add it and make it executable. # we make sure to add it and make it executable.
if ! grep -q -m1 "^#!/usr/bin/env node" out/node/entry.js; then if ! grep -q -m1 "^#!/usr/bin/env node" out/node/entry.js; then
@@ -18,12 +19,13 @@ main() {
fi fi
parcel build \ parcel build \
--public-url "/static/$(git rev-parse HEAD)/dist" \ --public-url "." \
--out-dir dist \ --out-dir dist \
$([[ $MINIFY ]] || echo --no-minify) \ $([[ $MINIFY ]] || echo --no-minify) \
src/browser/pages/app.ts \
src/browser/register.ts \ src/browser/register.ts \
src/browser/serviceWorker.ts src/browser/serviceWorker.ts \
src/browser/pages/login.ts \
src/browser/pages/vscode.ts
} }
main "$@" main "$@"

View File

@@ -21,6 +21,12 @@ main() {
rsync README.md "$RELEASE_PATH" rsync README.md "$RELEASE_PATH"
rsync LICENSE.txt "$RELEASE_PATH" rsync LICENSE.txt "$RELEASE_PATH"
rsync ./lib/vscode/ThirdPartyNotices.txt "$RELEASE_PATH" rsync ./lib/vscode/ThirdPartyNotices.txt "$RELEASE_PATH"
# code-server exports types which can be imported and used by plugins. Those
# types import ipc.d.ts but it isn't included in the final vscode build so
# we'll copy it ourselves here.
mkdir -p "$RELEASE_PATH/lib/vscode/src/vs/server"
rsync ./lib/vscode/src/vs/server/ipc.d.ts "$RELEASE_PATH/lib/vscode/src/vs/server"
} }
bundle_code_server() { bundle_code_server() {

View File

@@ -35,8 +35,7 @@ vscode_yarn() {
cd lib/vscode cd lib/vscode
yarn --production --frozen-lockfile yarn --production --frozen-lockfile
cd extensions cd extensions
# Cannot use --production here. The postinstall here uses a dev dependency. yarn --production --frozen-lockfile
yarn --frozen-lockfile
} }
main "$@" main "$@"

View File

@@ -15,7 +15,7 @@ main() {
./release-standalone/bin/code-server --extensions-dir "$EXTENSIONS_DIR" --install-extension ms-python.python ./release-standalone/bin/code-server --extensions-dir "$EXTENSIONS_DIR" --install-extension ms-python.python
local installed_extensions local installed_extensions
installed_extensions="$(./release-standalone/bin/code-server --extensions-dir "$EXTENSIONS_DIR" --list-extensions 2>&1)" installed_extensions="$(./release-standalone/bin/code-server --extensions-dir "$EXTENSIONS_DIR" --list-extensions 2>&1)"
if [[ $installed_extensions != "info Using config file ~/.config/code-server/config.yaml if [[ $installed_extensions != *"info Using config file ~/.config/code-server/config.yaml
ms-python.python" ]]; then ms-python.python" ]]; then
echo "Unexpected output from listing extensions:" echo "Unexpected output from listing extensions:"
echo "$installed_extensions" echo "$installed_extensions"

View File

@@ -217,8 +217,42 @@ index 0000000000..88b720ceee
+ util.rimraf("out-vscode-min"), + util.rimraf("out-vscode-min"),
+ common.minifyTask("out-vscode") + common.minifyTask("out-vscode")
+)); +));
diff --git a/extensions/package.json b/extensions/package.json
index 7c668c9744..0778f4f7db 100644
--- a/extensions/package.json
+++ b/extensions/package.json
@@ -2,13 +2,14 @@
"name": "vscode-extensions",
"version": "0.0.1",
"description": "Dependencies shared by all extensions",
+ "dependencies_comment": "Move rimraf to dependencies because it is used in the postinstall script.",
"dependencies": {
+ "rimraf": "^3.0.2",
"typescript": "3.9.6"
},
"scripts": {
"postinstall": "node ./postinstall"
},
"devDependencies": {
- "rimraf": "^3.0.2"
}
}
diff --git a/extensions/postinstall.js b/extensions/postinstall.js
index da4fa3e9d0..50f3e1144f 100644
--- a/extensions/postinstall.js
+++ b/extensions/postinstall.js
@@ -24,6 +24,9 @@ function processRoot() {
rimraf.sync(filePath);
}
}
+
+ // Delete .bin so it doesn't contain broken symlinks that trip up nfpm.
+ rimraf.sync(path.join(__dirname, 'node_modules', '.bin'));
}
function processLib() {
diff --git a/package.json b/package.json diff --git a/package.json b/package.json
index 86e3d5140d..2e52256e49 100644 index 86e3d5140d..962050280c 100644
--- a/package.json --- a/package.json
+++ b/package.json +++ b/package.json
@@ -42,6 +42,9 @@ @@ -42,6 +42,9 @@
@@ -231,6 +265,15 @@ index 86e3d5140d..2e52256e49 100644
"applicationinsights": "1.0.8", "applicationinsights": "1.0.8",
"chokidar": "3.2.3", "chokidar": "3.2.3",
"graceful-fs": "4.2.3", "graceful-fs": "4.2.3",
@@ -185,5 +188,8 @@
"windows-foreground-love": "0.2.0",
"windows-mutex": "0.3.0",
"windows-process-tree": "0.2.4"
+ },
+ "resolutions": {
+ "minimist": "^1.2.5"
}
}
diff --git a/product.json b/product.json diff --git a/product.json b/product.json
index 5378b017c8..afdadda974 100644 index 5378b017c8..afdadda974 100644
--- a/product.json --- a/product.json
@@ -679,10 +722,10 @@ index eab8591492..26668701f7 100644
options.logService.error(`${logPrefix} socketFactory.connect() failed. Error:`); options.logService.error(`${logPrefix} socketFactory.connect() failed. Error:`);
diff --git a/src/vs/server/browser/client.ts b/src/vs/server/browser/client.ts diff --git a/src/vs/server/browser/client.ts b/src/vs/server/browser/client.ts
new file mode 100644 new file mode 100644
index 0000000000..649cf32f0a index 0000000000..3c0703b717
--- /dev/null --- /dev/null
+++ b/src/vs/server/browser/client.ts +++ b/src/vs/server/browser/client.ts
@@ -0,0 +1,264 @@ @@ -0,0 +1,189 @@
+import { Emitter } from 'vs/base/common/event'; +import { Emitter } from 'vs/base/common/event';
+import { URI } from 'vs/base/common/uri'; +import { URI } from 'vs/base/common/uri';
+import { localize } from 'vs/nls'; +import { localize } from 'vs/nls';
@@ -690,7 +733,6 @@ index 0000000000..649cf32f0a
+import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; +import { registerSingleton } from 'vs/platform/instantiation/common/extensions';
+import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; +import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection';
+import { ILocalizationsService } from 'vs/platform/localizations/common/localizations'; +import { ILocalizationsService } from 'vs/platform/localizations/common/localizations';
+import { ILogService } from 'vs/platform/log/common/log';
+import { INotificationService, Severity } from 'vs/platform/notification/common/notification'; +import { INotificationService, Severity } from 'vs/platform/notification/common/notification';
+import { Registry } from 'vs/platform/registry/common/platform'; +import { Registry } from 'vs/platform/registry/common/platform';
+import { PersistentConnectionEventType } from 'vs/platform/remote/common/remoteAgentConnection'; +import { PersistentConnectionEventType } from 'vs/platform/remote/common/remoteAgentConnection';
@@ -719,31 +761,12 @@ index 0000000000..649cf32f0a
+}; +};
+ +
+/** +/**
+ * Get options embedded in the HTML from the server. + * Get options embedded in the HTML.
+ */ + */
+export const getOptions = <T extends Options>(): T => { +export const getOptions = <T extends Options>(): T => {
+ if (typeof document === "undefined") {
+ return {} as T;
+ }
+ const el = document.getElementById("coder-options");
+ try { + try {
+ if (!el) { + return JSON.parse(document.getElementById("coder-options")!.getAttribute("data-settings")!);
+ throw new Error("no options element");
+ }
+ const value = el.getAttribute("data-settings");
+ if (!value) {
+ throw new Error("no options value");
+ }
+ const options = JSON.parse(value);
+ const parts = window.location.pathname.replace(/^\//g, "").split("/");
+ parts[parts.length - 1] = options.base;
+ const url = new URL(window.location.origin + "/" + parts.join("/"));
+ return {
+ ...options,
+ base: normalize(url.pathname, true),
+ };
+ } catch (error) { + } catch (error) {
+ console.warn(error);
+ return {} as T; + return {} as T;
+ } + }
+}; +};
@@ -852,61 +875,6 @@ index 0000000000..649cf32f0a
+ }); + });
+ } + }
+ +
+ const applyUpdate = async (): Promise<void> => {
+ (services.get(ILogService) as ILogService).debug("Applying update...");
+
+ const response = await fetch(normalize(`${options.base}/update/apply`), {
+ headers: { "content-type": "application/json" },
+ });
+ const json = await response.json();
+ if (response.status !== 200 || json.error) {
+ throw new Error(json.error || response.statusText);
+ }
+ (services.get(INotificationService) as INotificationService).info(`Updated to ${json.version}`);
+ };
+
+ const getUpdate = async (): Promise<void> => {
+ (services.get(ILogService) as ILogService).debug("Checking for update...");
+
+ const response = await fetch(normalize(`${options.base}/update`), {
+ headers: { "content-type": "application/json" },
+ });
+ const json = await response.json();
+ if (response.status !== 200 || json.error) {
+ throw new Error(json.error || response.statusText);
+ }
+ if (json.isLatest) {
+ return;
+ }
+
+ (services.get(INotificationService) as INotificationService).notify({
+ severity: Severity.Info,
+ message: `code-server has an update: ${json.version}`,
+ actions: {
+ primary: [{
+ id: 'update',
+ label: 'Apply Update',
+ tooltip: '',
+ class: undefined,
+ enabled: true,
+ checked: true,
+ dispose: () => undefined,
+ run: applyUpdate,
+ }],
+ }
+ });
+ };
+
+ const updateLoop = (): void => {
+ getUpdate().catch((error) => {
+ (services.get(ILogService) as ILogService).warn(error);
+ }).finally(() => {
+ setTimeout(updateLoop, 300000);
+ });
+ };
+
+ updateLoop();
+
+ // This will be used to set the background color while VS Code loads. + // This will be used to set the background color while VS Code loads.
+ const theme = (services.get(IStorageService) as IStorageService).get("colorThemeData", StorageScope.GLOBAL); + const theme = (services.get(IStorageService) as IStorageService).get("colorThemeData", StorageScope.GLOBAL);
+ if (theme) { + if (theme) {
@@ -1319,17 +1287,15 @@ index 0000000000..56331ff1fc
+require('../../bootstrap-amd').load('vs/server/entry'); +require('../../bootstrap-amd').load('vs/server/entry');
diff --git a/src/vs/server/ipc.d.ts b/src/vs/server/ipc.d.ts diff --git a/src/vs/server/ipc.d.ts b/src/vs/server/ipc.d.ts
new file mode 100644 new file mode 100644
index 0000000000..0a9c95d50e index 0000000000..7e1cd270c8
--- /dev/null --- /dev/null
+++ b/src/vs/server/ipc.d.ts +++ b/src/vs/server/ipc.d.ts
@@ -0,0 +1,117 @@ @@ -0,0 +1,115 @@
+/** +/**
+ * External interfaces for integration into code-server over IPC. No vs imports + * External interfaces for integration into code-server over IPC. No vs imports
+ * should be made in this file. + * should be made in this file.
+ */ + */
+export interface Options { +export interface Options {
+ base: string
+ commit: string
+ disableTelemetry: boolean + disableTelemetry: boolean
+} +}
+ +
@@ -3431,7 +3397,7 @@ index 153ac595d0..a6eb49c5dd 100644
import 'vs/workbench/services/credentials/browser/credentialsService'; import 'vs/workbench/services/credentials/browser/credentialsService';
import 'vs/workbench/services/url/browser/urlService'; import 'vs/workbench/services/url/browser/urlService';
diff --git a/yarn.lock b/yarn.lock diff --git a/yarn.lock b/yarn.lock
index 6bc96e8377..585401f144 100644 index 6bc96e8377..a2baf909d6 100644
--- a/yarn.lock --- a/yarn.lock
+++ b/yarn.lock +++ b/yarn.lock
@@ -140,6 +140,23 @@ @@ -140,6 +140,23 @@
@@ -3472,7 +3438,35 @@ index 6bc96e8377..585401f144 100644
just-debounce@^1.0.0: just-debounce@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/just-debounce/-/just-debounce-1.0.0.tgz#87fccfaeffc0b68cd19d55f6722943f929ea35ea" resolved "https://registry.yarnpkg.com/just-debounce/-/just-debounce-1.0.0.tgz#87fccfaeffc0b68cd19d55f6722943f929ea35ea"
@@ -6798,6 +6822,11 @@ p-try@^2.0.0: @@ -6009,26 +6033,11 @@ minimatch@0.3:
dependencies:
brace-expansion "^1.1.7"
-minimist@0.0.8:
- version "0.0.8"
- resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
- integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=
-
-minimist@^1.2.0:
- version "1.2.0"
- resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
- integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=
-
-minimist@^1.2.5:
+minimist@0.0.8, minimist@^1.2.0, minimist@^1.2.5, minimist@~0.0.1:
version "1.2.5"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
-minimist@~0.0.1:
- version "0.0.10"
- resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf"
- integrity sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=
-
minipass@^2.2.1, minipass@^2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.3.3.tgz#a7dcc8b7b833f5d368759cce544dccb55f50f233"
@@ -6798,6 +6807,11 @@ p-try@^2.0.0:
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.0.0.tgz#85080bb87c64688fa47996fe8f7dfbe8211760b1" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.0.0.tgz#85080bb87c64688fa47996fe8f7dfbe8211760b1"
integrity sha512-hMp0onDKIajHfIkdRk3P4CdCmErkYAxxDtP3Wx/4nZ3aGlau2VKh3mZpcuFkH27WQkL/3WBCPOktzA9ZOAnMQQ== integrity sha512-hMp0onDKIajHfIkdRk3P4CdCmErkYAxxDtP3Wx/4nZ3aGlau2VKh3mZpcuFkH27WQkL/3WBCPOktzA9ZOAnMQQ==

View File

@@ -37,6 +37,7 @@ class Watcher {
const vscode = cp.spawn("yarn", ["watch"], { cwd: this.vscodeSourcePath }) const vscode = cp.spawn("yarn", ["watch"], { cwd: this.vscodeSourcePath })
const tsc = cp.spawn("tsc", ["--watch", "--pretty", "--preserveWatchOutput"], { cwd: this.rootPath }) const tsc = cp.spawn("tsc", ["--watch", "--pretty", "--preserveWatchOutput"], { cwd: this.rootPath })
const plugin = cp.spawn("yarn", ["build", "--watch"], { cwd: process.env.PLUGIN_DIR })
const bundler = this.createBundler() const bundler = this.createBundler()
const cleanup = (code?: number | null): void => { const cleanup = (code?: number | null): void => {
@@ -48,6 +49,10 @@ class Watcher {
tsc.removeAllListeners() tsc.removeAllListeners()
tsc.kill() tsc.kill()
Watcher.log("killing plugin")
plugin.removeAllListeners()
plugin.kill()
if (server) { if (server) {
Watcher.log("killing server") Watcher.log("killing server")
server.removeAllListeners() server.removeAllListeners()
@@ -69,6 +74,10 @@ class Watcher {
Watcher.log("tsc terminated unexpectedly") Watcher.log("tsc terminated unexpectedly")
cleanup(code) cleanup(code)
}) })
plugin.on("exit", (code) => {
Watcher.log("plugin terminated unexpectedly")
cleanup(code)
})
const bundle = bundler.bundle().catch(() => { const bundle = bundler.bundle().catch(() => {
Watcher.log("parcel watcher terminated unexpectedly") Watcher.log("parcel watcher terminated unexpectedly")
cleanup(1) cleanup(1)
@@ -82,6 +91,7 @@ class Watcher {
vscode.stderr.on("data", (d) => process.stderr.write(d)) vscode.stderr.on("data", (d) => process.stderr.write(d))
tsc.stderr.on("data", (d) => process.stderr.write(d)) tsc.stderr.on("data", (d) => process.stderr.write(d))
plugin.stderr.on("data", (d) => process.stderr.write(d))
// From https://github.com/chalk/ansi-regex // From https://github.com/chalk/ansi-regex
const pattern = [ const pattern = [
@@ -140,21 +150,32 @@ class Watcher {
bundle.then(restartServer) bundle.then(restartServer)
} }
}) })
onLine(plugin, (line, original) => {
// tsc outputs blank lines; skip them.
if (line !== "") {
console.log("[plugin]", original)
}
if (line.includes("Watching for file changes")) {
bundle.then(restartServer)
}
})
} }
private createBundler(out = "dist"): Bundler { private createBundler(out = "dist"): Bundler {
return new Bundler( return new Bundler(
[ [
path.join(this.rootPath, "src/browser/pages/app.ts"),
path.join(this.rootPath, "src/browser/register.ts"), path.join(this.rootPath, "src/browser/register.ts"),
path.join(this.rootPath, "src/browser/serviceWorker.ts"), path.join(this.rootPath, "src/browser/serviceWorker.ts"),
path.join(this.rootPath, "src/browser/pages/login.ts"),
path.join(this.rootPath, "src/browser/pages/vscode.ts"),
], ],
{ {
outDir: path.join(this.rootPath, out), outDir: path.join(this.rootPath, out),
cacheDir: path.join(this.rootPath, ".cache"), cacheDir: path.join(this.rootPath, ".cache"),
minify: !!process.env.MINIFY, minify: !!process.env.MINIFY,
logLevel: 1, logLevel: 1,
publicUrl: "/static/development/dist", publicUrl: ".",
}, },
) )
} }

View File

@@ -19,7 +19,7 @@ Please refer to [VS Code's prerequisites](https://github.com/Microsoft/vscode/wi
Differences: Differences:
- We require a minimum of node v12 but later versions should work. - We require a minimum of node v12 but later versions should work.
- We use [fnpm](https://github.com/goreleaser/nfpm) to build `.deb` and `.rpm` packages. - We use [nfpm](https://github.com/goreleaser/nfpm) to build `.deb` and `.rpm` packages.
- We use [jq](https://stedolan.github.io/jq/) to build code-server releases. - We use [jq](https://stedolan.github.io/jq/) to build code-server releases.
- The [CI container](../ci/images/debian8/Dockerfile) is a useful reference for all our dependencies. - The [CI container](../ci/images/debian8/Dockerfile) is a useful reference for all our dependencies.

View File

@@ -86,9 +86,20 @@ If you have your own marketplace that implements the VS Code Extension Gallery A
point code-server to it by setting `$SERVICE_URL` and `$ITEM_URL`. These correspond directly point code-server to it by setting `$SERVICE_URL` and `$ITEM_URL`. These correspond directly
to `serviceUrl` and `itemUrl` in VS Code's `product.json`. to `serviceUrl` and `itemUrl` in VS Code's `product.json`.
e.g. to use [open-vsx.org](https://open-vsx.org):
```bash
export SERVICE_URL=https://open-vsx.org/vscode/gallery
export ITEM_URL=https://open-vsx.org/vscode/item
```
While you can technically use Microsoft's marketplace with these, please do not do so as it While you can technically use Microsoft's marketplace with these, please do not do so as it
is against their terms of use. See [above](#differences-compared-to-vs-code). These variables is against their terms of use. See [above](#differences-compared-to-vs-code) and this
are most valuable to our enterprise customers for whom we have a self hosted marketplace product. discussion regarding the use of the Microsoft URLs in forks:
https://github.com/microsoft/vscode/issues/31168#issue-244533026
These variables are most valuable to our enterprise customers for whom we have a self hosted marketplace product.
## Where are extensions stored? ## Where are extensions stored?

View File

@@ -64,7 +64,7 @@
"vfile-message": "^2.0.2" "vfile-message": "^2.0.2"
}, },
"dependencies": { "dependencies": {
"@coder/logger": "1.1.11", "@coder/logger": "1.1.16",
"env-paths": "^2.2.0", "env-paths": "^2.2.0",
"fs-extra": "^8.1.0", "fs-extra": "^8.1.0",
"http-proxy": "^1.18.0", "http-proxy": "^1.18.0",
@@ -72,7 +72,7 @@
"js-yaml": "^3.13.1", "js-yaml": "^3.13.1",
"limiter": "^1.1.5", "limiter": "^1.1.5",
"pem": "^1.14.2", "pem": "^1.14.2",
"safe-buffer": "^5.1.1", "rotating-file-stream": "^2.1.1",
"safe-compare": "^1.1.4", "safe-compare": "^1.1.4",
"semver": "^7.1.3", "semver": "^7.1.3",
"tar": "^6.0.1", "tar": "^6.0.1",

View File

@@ -7,32 +7,32 @@
"description": "Run editors on a remote server.", "description": "Run editors on a remote server.",
"icons": [ "icons": [
{ {
"src": "{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-96.png", "src": "{{CS_STATIC_BASE}}/src/browser/media/pwa-icon-96.png",
"type": "image/png", "type": "image/png",
"sizes": "96x96" "sizes": "96x96"
}, },
{ {
"src": "{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-128.png", "src": "{{CS_STATIC_BASE}}/src/browser/media/pwa-icon-128.png",
"type": "image/png", "type": "image/png",
"sizes": "128x128" "sizes": "128x128"
}, },
{ {
"src": "{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-192.png", "src": "{{CS_STATIC_BASE}}/src/browser/media/pwa-icon-192.png",
"type": "image/png", "type": "image/png",
"sizes": "192x192" "sizes": "192x192"
}, },
{ {
"src": "{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-256.png", "src": "{{CS_STATIC_BASE}}/src/browser/media/pwa-icon-256.png",
"type": "image/png", "type": "image/png",
"sizes": "256x256" "sizes": "256x256"
}, },
{ {
"src": "{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-384.png", "src": "{{CS_STATIC_BASE}}/src/browser/media/pwa-icon-384.png",
"type": "image/png", "type": "image/png",
"sizes": "384x384" "sizes": "384x384"
}, },
{ {
"src": "{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-512.png", "src": "{{CS_STATIC_BASE}}/src/browser/media/pwa-icon-512.png",
"type": "image/png", "type": "image/png",
"sizes": "512x512" "sizes": "512x512"
} }

View File

@@ -1,28 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"
/>
<meta
http-equiv="Content-Security-Policy"
content="style-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:; font-src 'self' data:;"
/>
<title>code-server</title>
<link rel="icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
<link
rel="manifest"
href="{{BASE}}/static/{{COMMIT}}/src/browser/media/manifest.json"
crossorigin="use-credentials"
/>
<link rel="apple-touch-icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-384.png" />
<link href="{{BASE}}/static/{{COMMIT}}/dist/pages/app.css" rel="stylesheet" />
<meta id="coder-options" data-settings="{{OPTIONS}}" />
</head>
<body>
<script data-cfasync="false" src="{{BASE}}/static/{{COMMIT}}/dist/register.js"></script>
<script data-cfasync="false" src="{{BASE}}/static/{{COMMIT}}/dist/pages/app.js"></script>
</body>
</html>

View File

@@ -1,37 +0,0 @@
import { getOptions, normalize } from "../../common/util"
import { ApiEndpoint } from "../../common/http"
import "./error.css"
import "./global.css"
import "./home.css"
import "./login.css"
import "./update.css"
const options = getOptions()
const isInput = (el: Element): el is HTMLInputElement => {
return !!(el as HTMLInputElement).name
}
document.querySelectorAll("form").forEach((form) => {
if (!form.classList.contains("-x11")) {
return
}
form.addEventListener("submit", (event) => {
event.preventDefault()
const values: { [key: string]: string } = {}
Array.from(form.elements).forEach((element) => {
if (isInput(element)) {
values[element.name] = element.value
}
})
fetch(normalize(`${options.base}/api/${ApiEndpoint.process}`), {
method: "POST",
body: JSON.stringify(values),
})
})
})
// TEMP: Until we can get the real ready event.
const event = new CustomEvent("ide-ready")
window.dispatchEvent(event)

View File

@@ -11,14 +11,10 @@
content="style-src 'self'; manifest-src 'self'; img-src 'self' data:; font-src 'self' data:;" content="style-src 'self'; manifest-src 'self'; img-src 'self' data:; font-src 'self' data:;"
/> />
<title>{{ERROR_TITLE}} - code-server</title> <title>{{ERROR_TITLE}} - code-server</title>
<link rel="icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" /> <link rel="icon" href="{{CS_STATIC_BASE}}/src/browser/media/favicon.ico" type="image/x-icon" />
<link <link rel="manifest" href="{{CS_STATIC_BASE}}/src/browser/media/manifest.json" crossorigin="use-credentials" />
rel="manifest" <link rel="apple-touch-icon" href="{{CS_STATIC_BASE}}/src/browser/media/pwa-icon-384.png" />
href="{{BASE}}/static/{{COMMIT}}/src/browser/media/manifest.json" <link href="{{CS_STATIC_BASE}}/dist/register.css" rel="stylesheet" />
crossorigin="use-credentials"
/>
<link rel="apple-touch-icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-384.png" />
<link href="{{BASE}}/static/{{COMMIT}}/dist/pages/app.css" rel="stylesheet" />
<meta id="coder-options" data-settings="{{OPTIONS}}" /> <meta id="coder-options" data-settings="{{OPTIONS}}" />
</head> </head>
<body> <body>
@@ -33,6 +29,6 @@
</div> </div>
</div> </div>
</div> </div>
<script data-cfasync="false" src="{{BASE}}/static/{{COMMIT}}/dist/register.js"></script> <script data-cfasync="false" src="{{CS_STATIC_BASE}}/dist/register.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,51 +0,0 @@
.block-row {
display: flex;
}
.block-row > .item {
flex: 1;
margin: 2px 0;
}
.block-row > button.item {
background: none;
border: none;
cursor: pointer;
text-align: left;
}
.block-row > .item > .sub {
font-size: 0.95em;
}
.block-row .-link {
color: rgb(87, 114, 245);
display: block;
text-decoration: none;
}
.block-row .-link:hover {
text-decoration: underline;
}
.block-row > .item > .icon {
height: 1rem;
margin-right: 5px;
vertical-align: top;
width: 1rem;
}
.block-row > .item > .icon.-missing {
background-color: rgba(87, 114, 245, 0.2);
display: inline-block;
text-align: center;
}
.kill-form {
display: inline-block;
}
.kill-form > .kill {
border-radius: 3px;
padding: 2px 5px;
}

View File

@@ -1,59 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"
/>
<meta
http-equiv="Content-Security-Policy"
content="style-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:; font-src 'self' data:;"
/>
<title>code-server</title>
<link rel="icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
<link
rel="manifest"
href="{{BASE}}/static/{{COMMIT}}/src/browser/media/manifest.json"
crossorigin="use-credentials"
/>
<link rel="apple-touch-icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-384.png" />
<link href="{{BASE}}/static/{{COMMIT}}/dist/pages/app.css" rel="stylesheet" />
<meta id="coder-options" data-settings="{{OPTIONS}}" />
</head>
<body>
<div class="center-container">
<div class="card-box">
<div class="header">
<h2 class="main">Editors</h2>
<div class="sub">Choose an editor to launch below.</div>
</div>
<div class="content">
{{APP_LIST:EDITORS}}
</div>
</div>
<div class="card-box">
<div class="header">
<h2 class="main">Other</h2>
<div class="sub">Choose an application to launch below.</div>
</div>
<div class="content">
{{APP_LIST:OTHER}}
</div>
</div>
<div class="card-box">
<div class="header">
<h2 class="main">Version</h2>
<div class="sub">Version information and updates.</div>
</div>
<div class="content">
{{UPDATE:NAME}}
</div>
</div>
</div>
<script data-cfasync="false" src="{{BASE}}/static/{{COMMIT}}/dist/register.js"></script>
<script data-cfasync="false" src="{{BASE}}/static/{{COMMIT}}/dist/pages/app.js"></script>
</body>
</html>

View File

@@ -11,14 +11,10 @@
content="style-src 'self'; script-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:; font-src 'self' data:;" content="style-src 'self'; script-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:; font-src 'self' data:;"
/> />
<title>code-server login</title> <title>code-server login</title>
<link rel="icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" /> <link rel="icon" href="{{CS_STATIC_BASE}}/src/browser/media/favicon.ico" type="image/x-icon" />
<link <link rel="manifest" href="{{CS_STATIC_BASE}}/src/browser/media/manifest.json" crossorigin="use-credentials" />
rel="manifest" <link rel="apple-touch-icon" href="{{CS_STATIC_BASE}}/src/browser/media/pwa-icon-384.png" />
href="{{BASE}}/static/{{COMMIT}}/src/browser/media/manifest.json" <link href="{{CS_STATIC_BASE}}/dist/register.css" rel="stylesheet" />
crossorigin="use-credentials"
/>
<link rel="apple-touch-icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-384.png" />
<link href="{{BASE}}/static/{{COMMIT}}/dist/pages/app.css" rel="stylesheet" />
<meta id="coder-options" data-settings="{{OPTIONS}}" /> <meta id="coder-options" data-settings="{{OPTIONS}}" />
</head> </head>
<body> <body>
@@ -50,11 +46,6 @@
</div> </div>
</div> </div>
</body> </body>
<script data-cfasync="false" src="{{BASE}}/static/{{COMMIT}}/dist/register.js"></script> <script data-cfasync="false" src="{{CS_STATIC_BASE}}/dist/register.js"></script>
<script> <script data-cfasync="false" src="{{CS_STATIC_BASE}}/dist/login.js"></script>
const parts = window.location.pathname.replace(/^\//g, "").split("/")
parts[parts.length - 1] = "{{BASE}}"
const url = new URL(window.location.origin + "/" + parts.join("/"))
document.getElementById("base").value = url.pathname
</script>
</html> </html>

View File

@@ -0,0 +1,7 @@
import { getOptions } from "../../common/util"
const options = getOptions()
const el = document.getElementById("base") as HTMLInputElement
if (el) {
el.value = options.base
}

View File

@@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"
/>
<meta
http-equiv="Content-Security-Policy"
content="style-src 'self'; manifest-src 'self'; img-src 'self' data:; font-src 'self' data:;"
/>
<title>code-server</title>
<link rel="icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
<link
rel="manifest"
href="{{BASE}}/static/{{COMMIT}}/src/browser/media/manifest.json"
crossorigin="use-credentials"
/>
<link rel="apple-touch-icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-384.png" />
<link href="{{BASE}}/static/{{COMMIT}}/dist/pages/app.css" rel="stylesheet" />
<meta id="coder-options" data-settings="{{OPTIONS}}" />
</head>
<body>
<div class="center-container">
<div class="card-box">
<div class="header">
<h1 class="main">Update</h1>
<div class="sub">Update code-server.</div>
</div>
<div class="content">
<form class="update-form" action="{{BASE}}/update/apply">
{{UPDATE_STATUS}} {{ERROR}}
<div class="links">
<a class="link" href="{{BASE}}{{TO}}">go home</a>
</div>
</form>
</div>
</div>
</div>
<script data-cfasync="false" src="{{BASE}}/static/{{COMMIT}}/dist/register.js"></script>
</body>
</html>

View File

@@ -24,21 +24,17 @@
<meta id="vscode-remote-nls-configuration" data-settings="{{NLS_CONFIGURATION}}" /> <meta id="vscode-remote-nls-configuration" data-settings="{{NLS_CONFIGURATION}}" />
<!-- Workbench Icon/Manifest/CSS --> <!-- Workbench Icon/Manifest/CSS -->
<link rel="icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" /> <link rel="icon" href="{{CS_STATIC_BASE}}/src/browser/media/favicon.ico" type="image/x-icon" />
<link <link rel="manifest" href="{{CS_STATIC_BASE}}/src/browser/media/manifest.json" crossorigin="use-credentials" />
rel="manifest"
href="{{BASE}}/static/{{COMMIT}}/src/browser/media/manifest.json"
crossorigin="use-credentials"
/>
<!-- PROD_ONLY <!-- PROD_ONLY
<link data-name="vs/workbench/workbench.web.api" rel="stylesheet" href="{{BASE}}/static/{{COMMIT}}/lib/vscode/out/vs/workbench/workbench.web.api.css"> <link data-name="vs/workbench/workbench.web.api" rel="stylesheet" href="{{CS_STATIC_BASE}}/lib/vscode/out/vs/workbench/workbench.web.api.css">
END_PROD_ONLY --> END_PROD_ONLY -->
<link rel="apple-touch-icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-384.png" /> <link rel="apple-touch-icon" href="{{CS_STATIC_BASE}}/src/browser/media/pwa-icon-384.png" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<!-- Prefetch to avoid waterfall --> <!-- Prefetch to avoid waterfall -->
<!-- PROD_ONLY <!-- PROD_ONLY
<link rel="prefetch" href="{{BASE}}/static/{{COMMIT}}/lib/vscode/node_modules/semver-umd/lib/semver-umd.js"> <link rel="prefetch" href="{{CS_STATIC_BASE}}/lib/vscode/node_modules/semver-umd/lib/semver-umd.js">
END_PROD_ONLY --> END_PROD_ONLY -->
<meta id="coder-options" data-settings="{{OPTIONS}}" /> <meta id="coder-options" data-settings="{{OPTIONS}}" />
@@ -47,65 +43,14 @@
<body aria-label=""></body> <body aria-label=""></body>
<!-- Startup (do not modify order of script tags!) --> <!-- Startup (do not modify order of script tags!) -->
<script> <script data-cfasync="false" src="{{CS_STATIC_BASE}}/dist/pages/vscode.js"></script>
const parts = window.location.pathname.replace(/^\//g, "").split("/") <script data-cfasync="false" src="{{CS_STATIC_BASE}}/dist/register.js"></script>
parts[parts.length - 1] = "{{BASE}}" <script data-cfasync="false" src="{{CS_STATIC_BASE}}/lib/vscode/out/vs/loader.js"></script>
const url = new URL(window.location.origin + "/" + parts.join("/"))
const staticBase = url.href.replace(/\/+$/, "") + "/static/{{COMMIT}}/lib/vscode"
let nlsConfig
try {
nlsConfig = JSON.parse(document.getElementById("vscode-remote-nls-configuration").getAttribute("data-settings"))
if (nlsConfig._resolvedLanguagePackCoreLocation) {
const bundles = Object.create(null)
nlsConfig.loadBundle = (bundle, language, cb) => {
let result = bundles[bundle]
if (result) {
return cb(undefined, result)
}
// FIXME: Only works if path separators are /.
const path = nlsConfig._resolvedLanguagePackCoreLocation + "/" + bundle.replace(/\//g, "!") + ".nls.json"
fetch(`${url.href}/resource/?path=${encodeURIComponent(path)}`)
.then((response) => response.json())
.then((json) => {
bundles[bundle] = json
cb(undefined, json)
})
.catch(cb)
}
}
} catch (error) {
/* Probably fine. */
}
self.require = {
baseUrl: `${staticBase}/out`,
paths: {
"vscode-textmate": `${staticBase}/node_modules/vscode-textmate/release/main`,
"vscode-oniguruma": `${staticBase}/node_modules/vscode-oniguruma/release/main`,
xterm: `${staticBase}/node_modules/xterm/lib/xterm.js`,
"xterm-addon-search": `${staticBase}/node_modules/xterm-addon-search/lib/xterm-addon-search.js`,
"xterm-addon-unicode11": `${staticBase}/node_modules/xterm-addon-unicode11/lib/xterm-addon-unicode11.js`,
"xterm-addon-webgl": `${staticBase}/node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js`,
"semver-umd": `${staticBase}/node_modules/semver-umd/lib/semver-umd.js`,
"iconv-lite-umd": `${staticBase}/node_modules/iconv-lite-umd/lib/iconv-lite-umd.js`,
jschardet: `${staticBase}/node_modules/jschardet/dist/jschardet.min.js`,
},
"vs/nls": nlsConfig,
}
</script>
<script data-cfasync="false" src="{{BASE}}/static/{{COMMIT}}/dist/register.js"></script>
<script data-cfasync="false" src="{{BASE}}/static/{{COMMIT}}/lib/vscode/out/vs/loader.js"></script>
<!-- PROD_ONLY <!-- PROD_ONLY
<script data-cfasync="false" src="{{BASE}}/static/{{COMMIT}}/lib/vscode/out/vs/workbench/workbench.web.api.nls.js"></script> <script data-cfasync="false" src="{{CS_STATIC_BASE}}/lib/vscode/out/vs/workbench/workbench.web.api.nls.js"></script>
<script data-cfasync="false" src="{{BASE}}/static/{{COMMIT}}/lib/vscode/out/vs/workbench/workbench.web.api.js"></script> <script data-cfasync="false" src="{{CS_STATIC_BASE}}/lib/vscode/out/vs/workbench/workbench.web.api.js"></script>
END_PROD_ONLY --> END_PROD_ONLY -->
<script> <script>
require(["vs/code/browser/workbench/workbench"], function () {}) require(["vs/code/browser/workbench/workbench"], function () {})
</script> </script>
<script>
try {
document.body.style.background = JSON.parse(localStorage.getItem("colorThemeData")).colorMap["editor.background"]
} catch (error) {
// Oh well.
}
</script>
</html> </html>

View File

@@ -0,0 +1,53 @@
import { getOptions } from "../../common/util"
const options = getOptions()
// TODO: Add proper types.
/* eslint-disable @typescript-eslint/no-explicit-any */
let nlsConfig: any
try {
nlsConfig = JSON.parse(document.getElementById("vscode-remote-nls-configuration")!.getAttribute("data-settings")!)
if (nlsConfig._resolvedLanguagePackCoreLocation) {
const bundles = Object.create(null)
nlsConfig.loadBundle = (bundle: any, _language: any, cb: any): void => {
const result = bundles[bundle]
if (result) {
return cb(undefined, result)
}
// FIXME: Only works if path separators are /.
const path = nlsConfig._resolvedLanguagePackCoreLocation + "/" + bundle.replace(/\//g, "!") + ".nls.json"
fetch(`{{BASE}}/resource/?path=${encodeURIComponent(path)}`)
.then((response) => response.json())
.then((json) => {
bundles[bundle] = json
cb(undefined, json)
})
.catch(cb)
}
}
} catch (error) {
/* Probably fine. */
}
;(self.require as any) = {
baseUrl: `${options.csStaticBase}/lib/vscode/out`,
paths: {
"vscode-textmate": `../node_modules/vscode-textmate/release/main`,
"vscode-oniguruma": `../node_modules/vscode-oniguruma/release/main`,
xterm: `../node_modules/xterm/lib/xterm.js`,
"xterm-addon-search": `../node_modules/xterm-addon-search/lib/xterm-addon-search.js`,
"xterm-addon-unicode11": `../node_modules/xterm-addon-unicode11/lib/xterm-addon-unicode11.js`,
"xterm-addon-webgl": `../node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js`,
"semver-umd": `../node_modules/semver-umd/lib/semver-umd.js`,
"iconv-lite-umd": `../node_modules/iconv-lite-umd/lib/iconv-lite-umd.js`,
jschardet: `../node_modules/jschardet/dist/jschardet.min.js`,
},
"vs/nls": nlsConfig,
}
try {
document.body.style.background = JSON.parse(localStorage.getItem("colorThemeData")!).colorMap["editor.background"]
} catch (error) {
// Oh well.
}

View File

@@ -2,13 +2,17 @@ import { getOptions, normalize } from "../common/util"
const options = getOptions() const options = getOptions()
import "./pages/error.css"
import "./pages/global.css"
import "./pages/login.css"
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {
const path = normalize(`${options.base}/static/${options.commit}/dist/serviceWorker.js`) const path = normalize(`${options.csStaticBase}/dist/serviceWorker.js`)
navigator.serviceWorker navigator.serviceWorker
.register(path, { .register(path, {
scope: options.base || "/", scope: options.base || "/",
}) })
.then(function () { .then(() => {
console.log("[Service Worker] registered") console.log("[Service Worker] registered")
}) })
} }

View File

@@ -1,60 +0,0 @@
export interface Application {
readonly categories?: string[]
readonly comment?: string
readonly directory?: string
readonly exec?: string
readonly genericName?: string
readonly icon?: string
readonly installed?: boolean
readonly name: string
/**
* Path if this is a browser app (like VS Code).
*/
readonly path?: string
/**
* PID if this is a process.
*/
readonly pid?: number
readonly version?: string
}
export interface ApplicationsResponse {
readonly applications: ReadonlyArray<Application>
}
export enum SessionError {
FailedToStart = 4000,
Starting = 4001,
InvalidState = 4002,
Unknown = 4003,
}
export interface SessionResponse {
/**
* Whether the process was spawned or an existing one was returned.
*/
created: boolean
pid: number
}
export interface RecentResponse {
readonly paths: string[]
readonly workspaces: string[]
}
export interface HealthRequest {
readonly event: "health"
}
export type ClientMessage = HealthRequest
export interface HealthResponse {
readonly event: "health"
readonly connections: number
}
export type ServerMessage = HealthResponse
export interface ReadyMessage {
protocol: string
}

View File

@@ -1,19 +1,21 @@
import { Callback } from "./types"
export interface Disposable { export interface Disposable {
dispose(): void dispose(): void
} }
export interface Event<T> { export interface Event<T> {
(listener: (value: T) => void): Disposable (listener: Callback<T>): Disposable
} }
/** /**
* Emitter typecasts for a single event type. * Emitter typecasts for a single event type.
*/ */
export class Emitter<T> { export class Emitter<T> {
private listeners: Array<(value: T) => void> = [] private listeners: Array<Callback<T>> = []
public get event(): Event<T> { public get event(): Event<T> {
return (cb: (value: T) => void): Disposable => { return (cb: Callback<T>): Disposable => {
this.listeners.push(cb) this.listeners.push(cb)
return { return {

View File

@@ -9,16 +9,8 @@ export enum HttpCode {
} }
export class HttpError extends Error { export class HttpError extends Error {
public constructor(message: string, public readonly code: number) { public constructor(message: string, public readonly code: number, public readonly details?: object) {
super(message) super(message)
this.name = this.constructor.name this.name = this.constructor.name
} }
} }
export enum ApiEndpoint {
applications = "/applications",
process = "/process",
recent = "/recent",
run = "/run",
status = "/status",
}

1
src/common/types.ts Normal file
View File

@@ -0,0 +1 @@
export type Callback<T, R = void> = (t: T) => R

View File

@@ -2,9 +2,8 @@ import { logger, field } from "@coder/logger"
export interface Options { export interface Options {
base: string base: string
commit: string csStaticBase: string
logLevel: number logLevel: number
pid?: number
} }
/** /**
@@ -16,7 +15,11 @@ export const split = (str: string, delimiter: string): [string, string] => {
return index !== -1 ? [str.substring(0, index).trim(), str.substring(index + 1)] : [str, ""] return index !== -1 ? [str.substring(0, index).trim(), str.substring(index + 1)] : [str, ""]
} }
export const plural = (count: number): string => (count === 1 ? "" : "s") /**
* Appends an 's' to the provided string if count is greater than one;
* otherwise the string is returned
*/
export const plural = (count: number, str: string): string => (count === 1 ? str : `${str}s`)
export const generateUuid = (length = 24): string => { export const generateUuid = (length = 24): string => {
const possible = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" const possible = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
@@ -33,21 +36,35 @@ export const normalize = (url: string, keepTrailing = false): string => {
return url.replace(/\/\/+/g, "/").replace(/\/+$/, keepTrailing ? "/" : "") return url.replace(/\/\/+/g, "/").replace(/\/+$/, keepTrailing ? "/" : "")
} }
/**
* Remove leading and trailing slashes.
*/
export const trimSlashes = (url: string): string => {
return url.replace(/^\/+|\/+$/g, "")
}
/**
* Resolve a relative base against the window location. This is used for
* anything that doesn't work with a relative path.
*/
export const resolveBase = (base?: string): string => {
// After resolving the base will either start with / or be an empty string.
if (!base || base.startsWith("/")) {
return base ?? ""
}
const parts = location.pathname.split("/")
parts[parts.length - 1] = base
const url = new URL(location.origin + "/" + parts.join("/"))
return normalize(url.pathname)
}
/** /**
* Get options embedded in the HTML or query params. * Get options embedded in the HTML or query params.
*/ */
export const getOptions = <T extends Options>(): T => { export const getOptions = <T extends Options>(): T => {
let options: T let options: T
try { try {
const el = document.getElementById("coder-options") options = JSON.parse(document.getElementById("coder-options")!.getAttribute("data-settings")!)
if (!el) {
throw new Error("no options element")
}
const value = el.getAttribute("data-settings")
if (!value) {
throw new Error("no options value")
}
options = JSON.parse(value)
} catch (error) { } catch (error) {
options = {} as T options = {} as T
} }
@@ -61,17 +78,26 @@ export const getOptions = <T extends Options>(): T => {
} }
} }
if (typeof options.logLevel !== "undefined") { logger.level = options.logLevel
logger.level = options.logLevel
} options.base = resolveBase(options.base)
if (options.base) { options.csStaticBase = resolveBase(options.csStaticBase)
const parts = location.pathname.replace(/^\//g, "").split("/")
parts[parts.length - 1] = options.base
const url = new URL(location.origin + "/" + parts.join("/"))
options.base = normalize(url.pathname, true)
}
logger.debug("got options", field("options", options)) logger.debug("got options", field("options", options))
return options return options
} }
/**
* Wrap the value in an array if it's not already an array. If the value is
* undefined return an empty array.
*/
export const arrayify = <T>(value?: T | T[]): T[] => {
if (Array.isArray(value)) {
return value
}
if (typeof value === "undefined") {
return []
}
return [value]
}

View File

@@ -1,312 +0,0 @@
import { field, logger } from "@coder/logger"
import * as cp from "child_process"
import * as fs from "fs-extra"
import * as http from "http"
import * as net from "net"
import * as path from "path"
import * as url from "url"
import * as WebSocket from "ws"
import {
Application,
ApplicationsResponse,
ClientMessage,
RecentResponse,
ServerMessage,
SessionError,
SessionResponse,
} from "../../common/api"
import { ApiEndpoint, HttpCode, HttpError } from "../../common/http"
import { HttpProvider, HttpProviderOptions, HttpResponse, HttpServer, Route } from "../http"
import { findApplications, findWhitelistedApplications, Vscode } from "./bin"
import { VscodeHttpProvider } from "./vscode"
interface VsRecents {
[key: string]: (string | { configURIPath: string })[]
}
type VsSettings = [string, string][]
/**
* API HTTP provider.
*/
export class ApiHttpProvider extends HttpProvider {
private readonly ws = new WebSocket.Server({ noServer: true })
public constructor(
options: HttpProviderOptions,
private readonly server: HttpServer,
private readonly vscode: VscodeHttpProvider,
private readonly dataDir?: string,
) {
super(options)
}
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
this.ensureAuthenticated(request)
if (!this.isRoot(route)) {
throw new HttpError("Not found", HttpCode.NotFound)
}
switch (route.base) {
case ApiEndpoint.applications:
this.ensureMethod(request)
return {
mime: "application/json",
content: {
applications: await this.applications(),
},
} as HttpResponse<ApplicationsResponse>
case ApiEndpoint.process:
return this.process(request)
case ApiEndpoint.recent:
this.ensureMethod(request)
return {
mime: "application/json",
content: await this.recent(),
} as HttpResponse<RecentResponse>
}
throw new HttpError("Not found", HttpCode.NotFound)
}
public async handleWebSocket(
route: Route,
request: http.IncomingMessage,
socket: net.Socket,
head: Buffer,
): Promise<void> {
if (!this.authenticated(request)) {
throw new Error("not authenticated")
}
switch (route.base) {
case ApiEndpoint.status:
return this.handleStatusSocket(request, socket, head)
case ApiEndpoint.run:
return this.handleRunSocket(route, request, socket, head)
}
throw new HttpError("Not found", HttpCode.NotFound)
}
private async handleStatusSocket(request: http.IncomingMessage, socket: net.Socket, head: Buffer): Promise<void> {
const getMessageResponse = async (event: "health"): Promise<ServerMessage> => {
switch (event) {
case "health":
return { event, connections: await this.server.getConnections() }
default:
throw new Error("unexpected message")
}
}
await new Promise<WebSocket>((resolve) => {
this.ws.handleUpgrade(request, socket, head, (ws) => {
const send = (event: ServerMessage): void => {
ws.send(JSON.stringify(event))
}
ws.on("message", (data) => {
logger.trace("got message", field("message", data))
try {
const message: ClientMessage = JSON.parse(data.toString())
getMessageResponse(message.event).then(send)
} catch (error) {
logger.error(error.message, field("message", data))
}
})
resolve()
})
})
}
/**
* A socket that connects to the process.
*/
private async handleRunSocket(
_route: Route,
request: http.IncomingMessage,
socket: net.Socket,
head: Buffer,
): Promise<void> {
logger.debug("connecting to process")
const ws = await new Promise<WebSocket>((resolve, reject) => {
this.ws.handleUpgrade(request, socket, head, (socket) => {
socket.binaryType = "arraybuffer"
socket.on("error", (error) => {
socket.close(SessionError.FailedToStart)
logger.error("got error while connecting socket", field("error", error))
reject(error)
})
resolve(socket as WebSocket)
})
})
logger.debug("connected to process")
// Send ready message.
ws.send(
Buffer.from(
JSON.stringify({
protocol: "TODO",
}),
),
)
}
/**
* Return whitelisted applications.
*/
public async applications(): Promise<ReadonlyArray<Application>> {
return findWhitelistedApplications()
}
/**
* Return installed applications.
*/
public async installedApplications(): Promise<ReadonlyArray<Application>> {
return findApplications()
}
/**
* Handle /process endpoint.
*/
private async process(request: http.IncomingMessage): Promise<HttpResponse> {
this.ensureMethod(request, ["DELETE", "POST"])
const data = await this.getData(request)
if (!data) {
throw new HttpError("No data was provided", HttpCode.BadRequest)
}
const parsed: Application = JSON.parse(data)
switch (request.method) {
case "DELETE":
if (parsed.pid) {
await this.killProcess(parsed.pid)
} else if (parsed.path) {
await this.killProcess(parsed.path)
} else {
throw new Error("No pid or path was provided")
}
return {
mime: "application/json",
code: HttpCode.Ok,
}
case "POST": {
if (!parsed.exec) {
throw new Error("No exec was provided")
}
return {
mime: "application/json",
content: {
created: true,
pid: await this.spawnProcess(parsed.exec),
},
} as HttpResponse<SessionResponse>
}
}
throw new HttpError("Not found", HttpCode.NotFound)
}
/**
* Kill a process identified by pid or path if a web app.
*/
public async killProcess(pid: number | string): Promise<void> {
if (typeof pid === "string") {
switch (pid) {
case Vscode.path:
await this.vscode.dispose()
break
default:
throw new Error(`Process "${pid}" does not exist`)
}
} else {
process.kill(pid)
}
}
/**
* Spawn a process and return the pid.
*/
public async spawnProcess(exec: string): Promise<number> {
const proc = cp.spawn(exec, {
shell: process.env.SHELL || true,
env: {
...process.env,
},
})
proc.on("error", (error) => logger.error("process errored", field("pid", proc.pid), field("error", error)))
proc.on("exit", () => logger.debug("process exited", field("pid", proc.pid)))
logger.debug("started process", field("pid", proc.pid))
return proc.pid
}
/**
* Return VS Code's recent paths.
*/
public async recent(): Promise<RecentResponse> {
try {
if (!this.dataDir) {
throw new Error("data directory is not set")
}
const state: VsSettings = JSON.parse(await fs.readFile(path.join(this.dataDir, "User/state/global.json"), "utf8"))
const setting = Array.isArray(state) && state.find((item) => item[0] === "recently.opened")
if (!setting) {
return { paths: [], workspaces: [] }
}
const pathPromises: { [key: string]: Promise<string> } = {}
const workspacePromises: { [key: string]: Promise<string> } = {}
Object.values(JSON.parse(setting[1]) as VsRecents).forEach((recents) => {
recents.forEach((recent) => {
try {
const target = typeof recent === "string" ? pathPromises : workspacePromises
const pathname = url.parse(typeof recent === "string" ? recent : recent.configURIPath).pathname
if (pathname && !target[pathname]) {
target[pathname] = new Promise<string>((resolve) => {
fs.stat(pathname)
.then(() => resolve(pathname))
.catch(() => resolve())
})
}
} catch (error) {
logger.debug("invalid path", field("path", recent))
}
})
})
const [paths, workspaces] = await Promise.all([
Promise.all(Object.values(pathPromises)),
Promise.all(Object.values(workspacePromises)),
])
return {
paths: paths.filter((p) => !!p),
workspaces: workspaces.filter((p) => !!p),
}
} catch (error) {
if (error.code !== "ENOENT") {
throw error
}
}
return { paths: [], workspaces: [] }
}
/**
* For these, just return the error message since they'll be requested as
* JSON.
*/
public async getErrorRoot(_route: Route, _title: string, _header: string, error: string): Promise<HttpResponse> {
return {
mime: "application/json",
content: JSON.stringify({ error }),
}
}
}

View File

@@ -1,30 +0,0 @@
import * as fs from "fs"
import * as path from "path"
import { Application } from "../../common/api"
const getVscodeVersion = (): string => {
try {
return require(path.resolve(__dirname, "../../../lib/vscode/package.json")).version
} catch (error) {
return "unknown"
}
}
export const Vscode: Application = {
categories: ["Editor"],
icon: fs.readFileSync(path.resolve(__dirname, "../../../lib/vscode/resources/linux/code.png")).toString("base64"),
installed: true,
name: "VS Code",
path: "/",
version: getVscodeVersion(),
}
export const findApplications = async (): Promise<ReadonlyArray<Application>> => {
const apps: Application[] = [Vscode]
return apps.sort((a, b): number => a.name.localeCompare(b.name))
}
export const findWhitelistedApplications = async (): Promise<ReadonlyArray<Application>> => {
return [Vscode]
}

View File

@@ -1,147 +0,0 @@
import * as http from "http"
import * as querystring from "querystring"
import { Application } from "../../common/api"
import { HttpCode, HttpError } from "../../common/http"
import { normalize } from "../../common/util"
import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http"
import { ApiHttpProvider } from "./api"
import { UpdateHttpProvider } from "./update"
/**
* Dashboard HTTP provider.
*/
export class DashboardHttpProvider extends HttpProvider {
public constructor(
options: HttpProviderOptions,
private readonly api: ApiHttpProvider,
private readonly update: UpdateHttpProvider,
) {
super(options)
}
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
if (!this.isRoot(route)) {
throw new HttpError("Not found", HttpCode.NotFound)
}
switch (route.base) {
case "/spawn": {
this.ensureAuthenticated(request)
this.ensureMethod(request, "POST")
const data = await this.getData(request)
const app = data ? querystring.parse(data) : {}
if (app.path) {
return { redirect: Array.isArray(app.path) ? app.path[0] : app.path }
}
if (!app.exec) {
throw new Error("No exec was provided")
}
this.api.spawnProcess(Array.isArray(app.exec) ? app.exec[0] : app.exec)
return { redirect: this.options.base }
}
case "/app":
case "/": {
this.ensureMethod(request)
if (!this.authenticated(request)) {
return { redirect: "/login", query: { to: this.options.base } }
}
return route.base === "/" ? this.getRoot(route) : this.getAppRoot(route)
}
}
throw new HttpError("Not found", HttpCode.NotFound)
}
public async getRoot(route: Route): Promise<HttpResponse> {
const base = this.base(route)
const apps = await this.api.installedApplications()
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/home.html")
response.content = response.content
.replace(/{{UPDATE:NAME}}/, await this.getUpdate(base))
.replace(
/{{APP_LIST:EDITORS}}/,
this.getAppRows(
base,
apps.filter((app) => app.categories && app.categories.includes("Editor")),
),
)
.replace(
/{{APP_LIST:OTHER}}/,
this.getAppRows(
base,
apps.filter((app) => !app.categories || !app.categories.includes("Editor")),
),
)
return this.replaceTemplates(route, response)
}
public async getAppRoot(route: Route): Promise<HttpResponse> {
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/app.html")
return this.replaceTemplates(route, response)
}
private getAppRows(base: string, apps: ReadonlyArray<Application>): string {
return apps.length > 0
? apps.map((app) => this.getAppRow(base, app)).join("\n")
: `<div class="none">No applications found.</div>`
}
private getAppRow(base: string, app: Application): string {
return `<form class="block-row${app.exec ? " -x11" : ""}" method="post" action="${normalize(
`${base}${this.options.base}/spawn`,
)}">
<button class="item -row -link">
<input type="hidden" name="path" value="${app.path || ""}">
<input type="hidden" name="exec" value="${app.exec || ""}">
${
app.icon
? `<img class="icon" src="data:image/png;base64,${app.icon}"></img>`
: `<span class="icon -missing"></span>`
}
<span class="name">${app.name}</span>
</button>
</form>`
}
private async getUpdate(base: string): Promise<string> {
if (!this.update.enabled) {
return `<div class="block-row"><div class="item"><div class="sub">Updates are disabled</div></div></div>`
}
const humanize = (time: number): string => {
const d = new Date(time)
const pad = (t: number): string => (t < 10 ? "0" : "") + t
return (
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
` ${pad(d.getHours())}:${pad(d.getMinutes())}`
)
}
const update = await this.update.getUpdate()
if (this.update.isLatestVersion(update)) {
return `<div class="block-row">
<div class="item">
Latest: ${update.version}
<div class="sub">Up to date</div>
</div>
<div class="item">
${humanize(update.checked)}
<a class="sub -link" href="${base}/update/check?to=${this.options.base}">Check now</a>
</div>
<div class="item" >Current: ${this.update.currentVersion}</div>
</div>`
}
return `<div class="block-row">
<div class="item">
Latest: ${update.version}
<div class="sub">Out of date</div>
</div>
<div class="item">
${humanize(update.checked)}
<a class="sub -link" href="${base}/update?to=${this.options.base}">Update now</a>
</div>
<div class="item" >Current: ${this.update.currentVersion}</div>
</div>`
}
}

View File

@@ -24,7 +24,7 @@ export class ProxyHttpProvider extends HttpProvider {
const port = route.base.replace(/^\//, "") const port = route.base.replace(/^\//, "")
return { return {
proxy: { proxy: {
base: `${this.options.base}/${port}`, strip: `${route.providerBase}/${port}`,
port, port,
}, },
} }
@@ -35,7 +35,7 @@ export class ProxyHttpProvider extends HttpProvider {
const port = route.base.replace(/^\//, "") const port = route.base.replace(/^\//, "")
return { return {
proxy: { proxy: {
base: `${this.options.base}/${port}`, strip: `${route.providerBase}/${port}`,
port, port,
}, },
} }

View File

@@ -8,10 +8,9 @@ import { HttpProvider, HttpResponse, Route } from "../http"
import { pathToFsPath } from "../util" import { pathToFsPath } from "../util"
/** /**
* Static file HTTP provider. Regular static requests (the path is the request * Static file HTTP provider. Static requests do not require authentication if
* itself) do not require authentication and they only allow access to resources * the resource is in the application's directory except requests to serve a
* within the application. Requests for tars (the path is in a query parameter) * directory as a tar which always requires authentication.
* do require permissions and can access any directory.
*/ */
export class StaticHttpProvider extends HttpProvider { export class StaticHttpProvider extends HttpProvider {
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> { public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
@@ -22,7 +21,7 @@ export class StaticHttpProvider extends HttpProvider {
return this.getTarredResource(request, pathToFsPath(route.query.tar)) return this.getTarredResource(request, pathToFsPath(route.query.tar))
} }
const response = await this.getReplacedResource(route) const response = await this.getReplacedResource(request, route)
if (!this.isDev) { if (!this.isDev) {
response.cache = true response.cache = true
} }
@@ -32,17 +31,25 @@ export class StaticHttpProvider extends HttpProvider {
/** /**
* Return a resource with variables replaced where necessary. * Return a resource with variables replaced where necessary.
*/ */
protected async getReplacedResource(route: Route): Promise<HttpResponse> { protected async getReplacedResource(request: http.IncomingMessage, route: Route): Promise<HttpResponse> {
// The first part is always the commit (for caching purposes). // The first part is always the commit (for caching purposes).
const split = route.requestPath.split("/").slice(1) const split = route.requestPath.split("/").slice(1)
const resourcePath = path.resolve("/", ...split)
// Make sure it's in code-server or a plugin.
const validPaths = [this.rootPath, process.env.PLUGIN_DIR]
if (!validPaths.find((p) => p && resourcePath.startsWith(p))) {
this.ensureAuthenticated(request)
}
switch (split[split.length - 1]) { switch (split[split.length - 1]) {
case "manifest.json": { case "manifest.json": {
const response = await this.getUtf8Resource(this.rootPath, ...split) const response = await this.getUtf8Resource(resourcePath)
return this.replaceTemplates(route, response) return this.replaceTemplates(route, response)
} }
} }
return this.getResource(this.rootPath, ...split) return this.getResource(resourcePath)
} }
/** /**

View File

@@ -1,21 +1,12 @@
import { field, logger } from "@coder/logger" import { field, logger } from "@coder/logger"
import * as cp from "child_process"
import * as fs from "fs-extra"
import * as http from "http" import * as http from "http"
import * as https from "https" import * as https from "https"
import * as os from "os"
import * as path from "path" import * as path from "path"
import * as semver from "semver" import * as semver from "semver"
import { Readable, Writable } from "stream"
import * as tar from "tar-fs"
import * as url from "url" import * as url from "url"
import * as util from "util"
import * as zlib from "zlib"
import { HttpCode, HttpError } from "../../common/http" import { HttpCode, HttpError } from "../../common/http"
import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http" import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http"
import { settings as globalSettings, SettingsProvider, UpdateSettings } from "../settings" import { settings as globalSettings, SettingsProvider, UpdateSettings } from "../settings"
import { tmpdir } from "../util"
import { ipcMain } from "../wrapper"
export interface Update { export interface Update {
checked: number checked: number
@@ -27,7 +18,7 @@ export interface LatestResponse {
} }
/** /**
* Update HTTP provider. * HTTP provider for checking updates (does not download/install them).
*/ */
export class UpdateHttpProvider extends HttpProvider { export class UpdateHttpProvider extends HttpProvider {
private update?: Promise<Update> private update?: Promise<Update>
@@ -41,12 +32,6 @@ export class UpdateHttpProvider extends HttpProvider {
* that fulfills `LatestResponse`. * that fulfills `LatestResponse`.
*/ */
private readonly latestUrl = "https://api.github.com/repos/cdr/code-server/releases/latest", private readonly latestUrl = "https://api.github.com/repos/cdr/code-server/releases/latest",
/**
* The URL for downloading a version of code-server. {{VERSION}} and
* {{RELEASE_NAME}} will be replaced (for example 2.1.0 and
* code-server-2.1.0-linux-x86_64.tar.gz).
*/
private readonly downloadUrl = "https://github.com/cdr/code-server/releases/download/{{VERSION}}/{{RELEASE_NAME}}",
/** /**
* Update information will be stored here. If not provided, the global * Update information will be stored here. If not provided, the global
* settings will be used. * settings will be used.
@@ -64,66 +49,30 @@ export class UpdateHttpProvider extends HttpProvider {
throw new HttpError("Not found", HttpCode.NotFound) throw new HttpError("Not found", HttpCode.NotFound)
} }
switch (route.base) { if (!this.enabled) {
case "/check": throw new Error("update checks are disabled")
this.getUpdate(true)
if (route.query && route.query.to) {
return {
redirect: Array.isArray(route.query.to) ? route.query.to[0] : route.query.to,
query: { to: undefined },
}
}
return this.getRoot(route, request)
case "/apply":
return this.tryUpdate(route, request)
case "/":
return this.getRoot(route, request)
} }
throw new HttpError("Not found", HttpCode.NotFound) switch (route.base) {
} case "/check":
case "/": {
public async getRoot( const update = await this.getUpdate(route.base === "/check")
route: Route,
request: http.IncomingMessage,
errorOrUpdate?: Update | Error,
): Promise<HttpResponse> {
if (request.headers["content-type"] === "application/json") {
if (!this.enabled) {
return { return {
content: { content: {
isLatest: true, ...update,
isLatest: this.isLatestVersion(update),
}, },
} }
} }
const update = await this.getUpdate()
return {
content: {
...update,
isLatest: this.isLatestVersion(update),
},
}
} }
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/update.html")
response.content = response.content throw new HttpError("Not found", HttpCode.NotFound)
.replace(
/{{UPDATE_STATUS}}/,
errorOrUpdate && !(errorOrUpdate instanceof Error)
? `Updated to ${errorOrUpdate.version}`
: await this.getUpdateHtml(),
)
.replace(/{{ERROR}}/, errorOrUpdate instanceof Error ? `<div class="error">${errorOrUpdate.message}</div>` : "")
return this.replaceTemplates(route, response)
} }
/** /**
* Query for and return the latest update. * Query for and return the latest update.
*/ */
public async getUpdate(force?: boolean): Promise<Update> { public async getUpdate(force?: boolean): Promise<Update> {
if (!this.enabled) {
throw new Error("updates are not enabled")
}
// Don't run multiple requests at a time. // Don't run multiple requests at a time.
if (!this.update) { if (!this.update) {
this.update = this._getUpdate(force) this.update = this._getUpdate(force)
@@ -171,128 +120,6 @@ export class UpdateHttpProvider extends HttpProvider {
} }
} }
private async getUpdateHtml(): Promise<string> {
if (!this.enabled) {
return "Updates are disabled"
}
const update = await this.getUpdate()
if (this.isLatestVersion(update)) {
return "No update available"
}
return `<button type="submit" class="apply -button">Update to ${update.version}</button>`
}
public async tryUpdate(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
try {
const update = await this.getUpdate()
if (!this.isLatestVersion(update)) {
await this.downloadAndApplyUpdate(update)
return this.getRoot(route, request, update)
}
return this.getRoot(route, request)
} catch (error) {
// For JSON requests propagate the error. Otherwise catch it so we can
// show the error inline with the update button instead of an error page.
if (request.headers["content-type"] === "application/json") {
throw error
}
return this.getRoot(route, error)
}
}
public async downloadAndApplyUpdate(update: Update, targetPath?: string): Promise<void> {
const releaseName = await this.getReleaseName(update)
const url = this.downloadUrl.replace("{{VERSION}}", update.version).replace("{{RELEASE_NAME}}", releaseName)
let downloadPath = path.join(tmpdir, "updates", releaseName)
fs.mkdirp(path.dirname(downloadPath))
const response = await this.requestResponse(url)
try {
downloadPath = await this.extractTar(response, downloadPath)
logger.debug("Downloaded update", field("path", downloadPath))
// The archive should have a directory inside at the top level with the
// same name as the archive.
const directoryPath = path.join(downloadPath, path.basename(downloadPath))
await fs.stat(directoryPath)
if (!targetPath) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
targetPath = path.resolve(__dirname, "../../../")
}
// Move the old directory to prevent potential data loss.
const backupPath = path.resolve(targetPath, `../${path.basename(targetPath)}.${Date.now().toString()}`)
logger.debug("Replacing files", field("target", targetPath), field("backup", backupPath))
await fs.move(targetPath, backupPath)
// Move the new directory.
await fs.move(directoryPath, targetPath)
await fs.remove(downloadPath)
if (process.send) {
ipcMain().relaunch(update.version)
}
} catch (error) {
response.destroy(error)
throw error
}
}
private async extractTar(response: Readable, downloadPath: string): Promise<string> {
downloadPath = downloadPath.replace(/\.tar\.gz$/, "")
logger.debug("Extracting tar", field("path", downloadPath))
response.pause()
await fs.remove(downloadPath)
const decompress = zlib.createGunzip()
response.pipe(decompress as Writable)
response.on("error", (error) => decompress.destroy(error))
response.on("close", () => decompress.end())
const destination = tar.extract(downloadPath)
decompress.pipe(destination)
decompress.on("error", (error) => destination.destroy(error))
decompress.on("close", () => destination.end())
await new Promise((resolve, reject) => {
destination.on("finish", resolve)
destination.on("error", reject)
response.resume()
})
return downloadPath
}
/**
* Given an update return the name for the packaged archived.
*/
public async getReleaseName(update: Update): Promise<string> {
let target: string = os.platform()
if (target === "linux") {
const result = await util
.promisify(cp.exec)("ldd --version")
.catch((error) => ({
stderr: error.message,
stdout: "",
}))
if (/musl/.test(result.stderr) || /musl/.test(result.stdout)) {
target = "alpine"
}
}
let arch = os.arch()
if (arch === "x64") {
arch = "x86_64"
}
return `code-server-${update.version}-${target}-${arch}.tar.gz`
}
private async request(uri: string): Promise<Buffer> { private async request(uri: string): Promise<Buffer> {
const response = await this.requestResponse(uri) const response = await this.requestResponse(uri)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View File

@@ -14,7 +14,7 @@ import {
WorkbenchOptions, WorkbenchOptions,
} from "../../../lib/vscode/src/vs/server/ipc" } from "../../../lib/vscode/src/vs/server/ipc"
import { HttpCode, HttpError } from "../../common/http" import { HttpCode, HttpError } from "../../common/http"
import { generateUuid } from "../../common/util" import { arrayify, generateUuid } from "../../common/util"
import { Args } from "../cli" import { Args } from "../cli"
import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http" import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http"
import { settings } from "../settings" import { settings } from "../settings"
@@ -131,7 +131,7 @@ export class VscodeHttpProvider extends HttpProvider {
if (!this.isRoot(route)) { if (!this.isRoot(route)) {
throw new HttpError("Not found", HttpCode.NotFound) throw new HttpError("Not found", HttpCode.NotFound)
} else if (!this.authenticated(request)) { } else if (!this.authenticated(request)) {
return { redirect: "/login", query: { to: this.options.base } } return { redirect: "/login", query: { to: route.providerBase } }
} }
try { try {
return await this.getRoot(request, route) return await this.getRoot(request, route)
@@ -183,11 +183,10 @@ export class VscodeHttpProvider extends HttpProvider {
}), }),
]) ])
if (startPath) { settings.write({
settings.write({ lastVisited: startPath || lastVisited, // If startpath is undefined, then fallback to lastVisited
lastVisited: startPath, query: route.query,
}) })
}
if (!this.isDev) { if (!this.isDev) {
response.content = response.content.replace(/<!-- PROD_ONLY/g, "").replace(/END_PROD_ONLY -->/g, "") response.content = response.content.replace(/<!-- PROD_ONLY/g, "").replace(/END_PROD_ONLY -->/g, "")
@@ -201,8 +200,6 @@ export class VscodeHttpProvider extends HttpProvider {
.replace(`"{{WORKBENCH_WEB_CONFIGURATION}}"`, `'${JSON.stringify(options.workbenchWebConfiguration)}'`) .replace(`"{{WORKBENCH_WEB_CONFIGURATION}}"`, `'${JSON.stringify(options.workbenchWebConfiguration)}'`)
.replace(`"{{NLS_CONFIGURATION}}"`, `'${JSON.stringify(options.nlsConfiguration)}'`) .replace(`"{{NLS_CONFIGURATION}}"`, `'${JSON.stringify(options.nlsConfiguration)}'`)
return this.replaceTemplates<Options>(route, response, { return this.replaceTemplates<Options>(route, response, {
base: this.base(route),
commit: this.options.commit,
disableTelemetry: !!this.args["disable-telemetry"], disableTelemetry: !!this.args["disable-telemetry"],
}) })
} }
@@ -224,8 +221,7 @@ export class VscodeHttpProvider extends HttpProvider {
} }
for (let i = 0; i < startPaths.length; ++i) { for (let i = 0; i < startPaths.length; ++i) {
const startPath = startPaths[i] const startPath = startPaths[i]
const url = const url = arrayify(startPath && startPath.url).find((p) => !!p)
startPath && (typeof startPath.url === "string" ? [startPath.url] : startPath.url || []).find((p) => !!p)
if (startPath && url) { if (startPath && url) {
return { return {
url, url,

View File

@@ -172,7 +172,7 @@ export const parse = (
const arg = argv[i] const arg = argv[i]
// -- signals the end of option parsing. // -- signals the end of option parsing.
if (!ended && arg == "--") { if (!ended && arg === "--") {
ended = true ended = true
continue continue
} }
@@ -220,7 +220,7 @@ export const parse = (
throw error(`--${key} requires a value`) throw error(`--${key} requires a value`)
} }
if (option.type == OptionalString && value == "false") { if (option.type === OptionalString && value === "false") {
continue continue
} }

View File

@@ -2,8 +2,6 @@ import { field, logger } from "@coder/logger"
import * as cp from "child_process" import * as cp from "child_process"
import * as path from "path" import * as path from "path"
import { CliMessage } from "../../lib/vscode/src/vs/server/ipc" import { CliMessage } from "../../lib/vscode/src/vs/server/ipc"
import { ApiHttpProvider } from "./app/api"
import { DashboardHttpProvider } from "./app/dashboard"
import { LoginHttpProvider } from "./app/login" import { LoginHttpProvider } from "./app/login"
import { ProxyHttpProvider } from "./app/proxy" import { ProxyHttpProvider } from "./app/proxy"
import { StaticHttpProvider } from "./app/static" import { StaticHttpProvider } from "./app/static"
@@ -11,8 +9,10 @@ import { UpdateHttpProvider } from "./app/update"
import { VscodeHttpProvider } from "./app/vscode" import { VscodeHttpProvider } from "./app/vscode"
import { Args, bindAddrFromAllSources, optionDescriptions, parse, readConfigFile, setDefaults } from "./cli" import { Args, bindAddrFromAllSources, optionDescriptions, parse, readConfigFile, setDefaults } from "./cli"
import { AuthType, HttpServer, HttpServerOptions } from "./http" import { AuthType, HttpServer, HttpServerOptions } from "./http"
import { generateCertificate, hash, open, humanPath } from "./util" import { loadPlugins } from "./plugin"
import { generateCertificate, hash, humanPath, open } from "./util"
import { ipcMain, wrap } from "./wrapper" import { ipcMain, wrap } from "./wrapper"
import { plural } from "../common/util"
process.on("uncaughtException", (error) => { process.on("uncaughtException", (error) => {
logger.error(`Uncaught exception: ${error.message}`) logger.error(`Uncaught exception: ${error.message}`)
@@ -73,15 +73,19 @@ const main = async (args: Args, cliArgs: Args, configArgs: Args): Promise<void>
} }
const httpServer = new HttpServer(options) const httpServer = new HttpServer(options)
const vscode = httpServer.registerHttpProvider("/", VscodeHttpProvider, args) httpServer.registerHttpProvider(["/", "/vscode"], VscodeHttpProvider, args)
const api = httpServer.registerHttpProvider("/api", ApiHttpProvider, httpServer, vscode, args["user-data-dir"]) httpServer.registerHttpProvider("/update", UpdateHttpProvider, false)
const update = httpServer.registerHttpProvider("/update", UpdateHttpProvider, false)
httpServer.registerHttpProvider("/proxy", ProxyHttpProvider) httpServer.registerHttpProvider("/proxy", ProxyHttpProvider)
httpServer.registerHttpProvider("/login", LoginHttpProvider, args.config!, envPassword) httpServer.registerHttpProvider("/login", LoginHttpProvider, args.config!, envPassword)
httpServer.registerHttpProvider("/static", StaticHttpProvider) httpServer.registerHttpProvider("/static", StaticHttpProvider)
httpServer.registerHttpProvider("/dashboard", DashboardHttpProvider, api, update)
ipcMain().onDispose(() => httpServer.dispose()) await loadPlugins(httpServer, args)
ipcMain().onDispose(() => {
httpServer.dispose().then((errors) => {
errors.forEach((error) => logger.error(error.message))
})
})
logger.info(`code-server ${version} ${commit}`) logger.info(`code-server ${version} ${commit}`)
const serverAddress = await httpServer.listen() const serverAddress = await httpServer.listen()
@@ -110,7 +114,7 @@ const main = async (args: Args, cliArgs: Args, configArgs: Args): Promise<void>
} }
if (httpServer.proxyDomains.size > 0) { if (httpServer.proxyDomains.size > 0) {
logger.info(` - Proxying the following domain${httpServer.proxyDomains.size === 1 ? "" : "s"}:`) logger.info(` - ${plural(httpServer.proxyDomains.size, "Proxying the following domain")}:`)
httpServer.proxyDomains.forEach((domain) => logger.info(` - *.${domain}`)) httpServer.proxyDomains.forEach((domain) => logger.info(` - *.${domain}`))
} }

View File

@@ -12,7 +12,7 @@ import { Readable } from "stream"
import * as tls from "tls" import * as tls from "tls"
import * as url from "url" import * as url from "url"
import { HttpCode, HttpError } from "../common/http" import { HttpCode, HttpError } from "../common/http"
import { normalize, Options, plural, split } from "../common/util" import { arrayify, normalize, Options, plural, split, trimSlashes } from "../common/util"
import { SocketProxyProvider } from "./socket" import { SocketProxyProvider } from "./socket"
import { getMediaMime, paths } from "./util" import { getMediaMime, paths } from "./util"
@@ -36,9 +36,13 @@ export type Query = { [key: string]: string | string[] | undefined }
export interface ProxyOptions { export interface ProxyOptions {
/** /**
* A base path to strip from from the request before proxying if necessary. * A path to strip from from the beginning of the request before proxying
*/ */
base?: string strip?: string
/**
* A path to add to the beginning of the request before proxying.
*/
prepend?: string
/** /**
* The port to proxy. * The port to proxy.
*/ */
@@ -79,9 +83,8 @@ export interface HttpResponse<T = string | Buffer | object> {
*/ */
mime?: string mime?: string
/** /**
* Redirect to this path. Will rewrite against the base path but NOT the * Redirect to this path. This is constructed against the site base (not the
* provider endpoint so you must include it. This allows redirecting outside * provider's base).
* of your endpoint.
*/ */
redirect?: string redirect?: string
/** /**
@@ -133,12 +136,16 @@ export interface HttpServerOptions {
export interface Route { export interface Route {
/** /**
* Base path part (in /test/path it would be "/test"). * Provider base path part (for /provider/base/path it would be /provider).
*/
providerBase: string
/**
* Base path part (for /provider/base/path it would be /base).
*/ */
base: string base: string
/** /**
* Remaining part of the route (in /test/path it would be "/path"). It can be * Remaining part of the route after factoring out the base and provider base
* blank. * (for /provider/base/path it would be /path). It can be blank.
*/ */
requestPath: string requestPath: string
/** /**
@@ -161,7 +168,6 @@ interface ProviderRoute extends Route {
export interface HttpProviderOptions { export interface HttpProviderOptions {
readonly auth: AuthType readonly auth: AuthType
readonly base: string
readonly commit: string readonly commit: string
readonly password?: string readonly password?: string
} }
@@ -175,7 +181,7 @@ export abstract class HttpProvider {
public constructor(protected readonly options: HttpProviderOptions) {} public constructor(protected readonly options: HttpProviderOptions) {}
public dispose(): void { public async dispose(): Promise<void> {
// No default behavior. // No default behavior.
} }
@@ -203,11 +209,11 @@ export abstract class HttpProvider {
/** /**
* Get the base relative to the provided route. For each slash we need to go * Get the base relative to the provided route. For each slash we need to go
* up a directory. For example: * up a directory. For example:
* / => ./ * / => .
* /foo => ./ * /foo => .
* /foo/ => ./../ * /foo/ => ./..
* /foo/bar => ./../ * /foo/bar => ./..
* /foo/bar/ => ./../../ * /foo/bar/ => ./../..
*/ */
public base(route: Route): string { public base(route: Route): string {
const depth = (route.originalPath.match(/\//g) || []).length const depth = (route.originalPath.match(/\//g) || []).length
@@ -229,30 +235,23 @@ export abstract class HttpProvider {
/** /**
* Replace common templates strings. * Replace common templates strings.
*/ */
protected replaceTemplates(route: Route, response: HttpStringFileResponse, sessionId?: string): HttpStringFileResponse
protected replaceTemplates<T extends object>( protected replaceTemplates<T extends object>(
route: Route, route: Route,
response: HttpStringFileResponse, response: HttpStringFileResponse,
options: T, extraOptions?: Omit<T, "base" | "csStaticBase" | "logLevel">,
): HttpStringFileResponse
protected replaceTemplates(
route: Route,
response: HttpStringFileResponse,
sessionIdOrOptions?: string | object,
): HttpStringFileResponse { ): HttpStringFileResponse {
if (typeof sessionIdOrOptions === "undefined" || typeof sessionIdOrOptions === "string") { const base = this.base(route)
sessionIdOrOptions = { const options: Options = {
base: this.base(route), base,
commit: this.options.commit, csStaticBase: base + "/static/" + this.options.commit + this.rootPath,
logLevel: logger.level, logLevel: logger.level,
sessionID: sessionIdOrOptions, ...extraOptions,
} as Options
} }
response.content = response.content response.content = response.content
.replace(/{{COMMIT}}/g, this.options.commit)
.replace(/{{TO}}/g, Array.isArray(route.query.to) ? route.query.to[0] : route.query.to || "/dashboard") .replace(/{{TO}}/g, Array.isArray(route.query.to) ? route.query.to[0] : route.query.to || "/dashboard")
.replace(/{{BASE}}/g, this.base(route)) .replace(/{{BASE}}/g, options.base)
.replace(/"{{OPTIONS}}"/, `'${JSON.stringify(sessionIdOrOptions)}'`) .replace(/{{CS_STATIC_BASE}}/g, options.csStaticBase)
.replace(/"{{OPTIONS}}"/, `'${JSON.stringify(options)}'`)
return response return response
} }
@@ -281,7 +280,7 @@ export abstract class HttpProvider {
* Helper to error on invalid methods (default GET). * Helper to error on invalid methods (default GET).
*/ */
protected ensureMethod(request: http.IncomingMessage, method?: string | string[]): void { protected ensureMethod(request: http.IncomingMessage, method?: string | string[]): void {
const check = Array.isArray(method) ? method : [method || "GET"] const check = arrayify(method || "GET")
if (!request.method || !check.includes(request.method)) { if (!request.method || !check.includes(request.method)) {
throw new HttpError(`Unsupported method ${request.method}`, HttpCode.BadRequest) throw new HttpError(`Unsupported method ${request.method}`, HttpCode.BadRequest)
} }
@@ -475,7 +474,7 @@ export class HttpServer {
this.proxyDomains = new Set((options.proxyDomains || []).map((d) => d.replace(/^\*\./, ""))) this.proxyDomains = new Set((options.proxyDomains || []).map((d) => d.replace(/^\*\./, "")))
this.heart = new Heart(path.join(paths.data, "heartbeat"), async () => { this.heart = new Heart(path.join(paths.data, "heartbeat"), async () => {
const connections = await this.getConnections() const connections = await this.getConnections()
logger.trace(`${connections} active connection${plural(connections)}`) logger.trace(plural(connections, `${connections} active connection`))
return connections !== 0 return connections !== 0
}) })
this.protocol = this.options.cert ? "https" : "http" this.protocol = this.options.cert ? "https" : "http"
@@ -502,9 +501,15 @@ export class HttpServer {
}) })
} }
public dispose(): void { /**
* Stop and dispose everything. Return an array of disposal errors.
*/
public async dispose(): Promise<Error[]> {
this.socketProvider.stop() this.socketProvider.stop()
this.providers.forEach((p) => p.dispose()) const providers = Array.from(this.providers.values())
// Catch so all the errors can be seen rather than just the first one.
const responses = await Promise.all<Error | undefined>(providers.map((p) => p.dispose().catch((e) => e)))
return responses.filter<Error>((r): r is Error => typeof r !== "undefined")
} }
public async getConnections(): Promise<number> { public async getConnections(): Promise<number> {
@@ -518,41 +523,51 @@ export class HttpServer {
/** /**
* Register a provider for a top-level endpoint. * Register a provider for a top-level endpoint.
*/ */
public registerHttpProvider<T extends HttpProvider>(endpoint: string, provider: HttpProvider0<T>): T public registerHttpProvider<T extends HttpProvider>(endpoint: string | string[], provider: HttpProvider0<T>): T
public registerHttpProvider<A1, T extends HttpProvider>(endpoint: string, provider: HttpProvider1<A1, T>, a1: A1): T public registerHttpProvider<A1, T extends HttpProvider>(
endpoint: string | string[],
provider: HttpProvider1<A1, T>,
a1: A1,
): T
public registerHttpProvider<A1, A2, T extends HttpProvider>( public registerHttpProvider<A1, A2, T extends HttpProvider>(
endpoint: string, endpoint: string | string[],
provider: HttpProvider2<A1, A2, T>, provider: HttpProvider2<A1, A2, T>,
a1: A1, a1: A1,
a2: A2, a2: A2,
): T ): T
public registerHttpProvider<A1, A2, A3, T extends HttpProvider>( public registerHttpProvider<A1, A2, A3, T extends HttpProvider>(
endpoint: string, endpoint: string | string[],
provider: HttpProvider3<A1, A2, A3, T>, provider: HttpProvider3<A1, A2, A3, T>,
a1: A1, a1: A1,
a2: A2, a2: A2,
a3: A3, a3: A3,
): T ): T
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
public registerHttpProvider(endpoint: string, provider: any, ...args: any[]): any { public registerHttpProvider(endpoint: string | string[], provider: any, ...args: any[]): void {
endpoint = endpoint.replace(/^\/+|\/+$/g, "")
if (this.providers.has(`/${endpoint}`)) {
throw new Error(`${endpoint} is already registered`)
}
if (/\//.test(endpoint)) {
throw new Error(`Only top-level endpoints are supported (got ${endpoint})`)
}
const p = new provider( const p = new provider(
{ {
auth: this.options.auth || AuthType.None, auth: this.options.auth || AuthType.None,
base: `/${endpoint}`,
commit: this.options.commit, commit: this.options.commit,
password: this.options.password, password: this.options.password,
}, },
...args, ...args,
) )
this.providers.set(`/${endpoint}`, p) const endpoints = arrayify(endpoint).map(trimSlashes)
return p endpoints.forEach((endpoint) => {
if (/\//.test(endpoint)) {
throw new Error(`Only top-level endpoints are supported (got ${endpoint})`)
}
const existingProvider = this.providers.get(`/${endpoint}`)
this.providers.set(`/${endpoint}`, p)
if (existingProvider) {
logger.debug(`Overridding existing /${endpoint} provider`)
// If the existing provider isn't registered elsewhere we can dispose.
if (!Array.from(this.providers.values()).find((p) => p === existingProvider)) {
logger.debug(`Disposing existing /${endpoint} provider`)
existingProvider.dispose()
}
}
})
} }
/** /**
@@ -642,15 +657,17 @@ export class HttpServer {
e = new HttpError("Not found", HttpCode.NotFound) e = new HttpError("Not found", HttpCode.NotFound)
} }
const code = typeof e.code === "number" ? e.code : HttpCode.ServerError const code = typeof e.code === "number" ? e.code : HttpCode.ServerError
logger.debug("Request error", field("url", request.url), field("code", code)) logger.debug("Request error", field("url", request.url), field("code", code), field("error", error))
if (code >= HttpCode.ServerError) { if (code >= HttpCode.ServerError) {
logger.error(error.stack) logger.error(error.stack)
} }
if (request.headers["content-type"] === "application/json") { if (request.headers["content-type"] === "application/json") {
write({ write({
code, code,
mime: "application/json",
content: { content: {
error: e.message, error: e.message,
...(e.details || {}),
}, },
}) })
} else { } else {
@@ -759,7 +776,7 @@ export class HttpServer {
// that by shifting the next base out of the request path. // that by shifting the next base out of the request path.
let provider = this.providers.get(base) let provider = this.providers.get(base)
if (base !== "/" && provider) { if (base !== "/" && provider) {
return { ...parse(requestPath), fullPath, query: parsedUrl.query, provider, originalPath } return { ...parse(requestPath), providerBase: base, fullPath, query: parsedUrl.query, provider, originalPath }
} }
// Fall back to the top-level provider. // Fall back to the top-level provider.
@@ -767,7 +784,7 @@ export class HttpServer {
if (!provider) { if (!provider) {
throw new Error(`No provider for ${base}`) throw new Error(`No provider for ${base}`)
} }
return { base, fullPath, requestPath, query: parsedUrl.query, provider, originalPath } return { base, providerBase: "/", fullPath, requestPath, query: parsedUrl.query, provider, originalPath }
} }
/** /**
@@ -806,10 +823,11 @@ export class HttpServer {
// sure how best to get this information to the `proxyRes` event handler. // sure how best to get this information to the `proxyRes` event handler.
// For now I'm sticking it on the request object which is passed through to // For now I'm sticking it on the request object which is passed through to
// the event. // the event.
;(request as ProxyRequest).base = options.base ;(request as ProxyRequest).base = options.strip
const isHttp = response instanceof http.ServerResponse const isHttp = response instanceof http.ServerResponse
const path = options.base ? route.fullPath.replace(options.base, "") : route.fullPath const base = options.strip ? route.fullPath.replace(options.strip, "") : route.fullPath
const path = normalize("/" + (options.prepend || "") + "/" + base, true)
const proxyOptions: proxy.ServerOptions = { const proxyOptions: proxy.ServerOptions = {
changeOrigin: true, changeOrigin: true,
ignorePath: true, ignorePath: true,
@@ -850,6 +868,7 @@ export class HttpServer {
// isn't setting the host header to match the access domain. // isn't setting the host header to match the access domain.
host === "localhost" host === "localhost"
) { ) {
logger.debug("no valid cookie doman", field("host", host))
return undefined return undefined
} }
@@ -859,6 +878,7 @@ export class HttpServer {
} }
}) })
logger.debug("got cookie doman", field("host", host))
return host ? `Domain=${host}` : undefined return host ? `Domain=${host}` : undefined
} }

60
src/node/plugin.ts Normal file
View File

@@ -0,0 +1,60 @@
import { field, logger } from "@coder/logger"
import * as fs from "fs"
import * as path from "path"
import * as util from "util"
import { Args } from "./cli"
import { HttpServer } from "./http"
/* eslint-disable @typescript-eslint/no-var-requires */
export type Activate = (httpServer: HttpServer, args: Args) => void
export interface Plugin {
activate: Activate
}
/**
* Intercept imports so we can inject code-server when the plugin tries to
* import it.
*/
const originalLoad = require("module")._load
// eslint-disable-next-line @typescript-eslint/no-explicit-any
require("module")._load = function (request: string, parent: object, isMain: boolean): any {
return originalLoad.apply(this, [request.replace(/^code-server/, path.resolve(__dirname, "../..")), parent, isMain])
}
const loadPlugin = async (pluginPath: string, httpServer: HttpServer, args: Args): Promise<void> => {
try {
const plugin: Plugin = require(pluginPath)
plugin.activate(httpServer, args)
logger.debug("Loaded plugin", field("name", path.basename(pluginPath)))
} catch (error) {
if (error.code !== "MODULE_NOT_FOUND") {
logger.warn(error.message)
} else {
logger.error(error.message)
}
}
}
const _loadPlugins = async (httpServer: HttpServer, args: Args): Promise<void> => {
const pluginPath = path.resolve(__dirname, "../../plugins")
const files = await util.promisify(fs.readdir)(pluginPath, {
withFileTypes: true,
})
await Promise.all(files.map((file) => loadPlugin(path.join(pluginPath, file.name), httpServer, args)))
}
export const loadPlugins = async (httpServer: HttpServer, args: Args): Promise<void> => {
try {
await _loadPlugins(httpServer, args)
} catch (error) {
if (error.code !== "ENOENT") {
logger.warn(error.message)
}
}
if (process.env.PLUGIN_DIR) {
await loadPlugin(process.env.PLUGIN_DIR, httpServer, args)
}
}

View File

@@ -1,7 +1,8 @@
import { logger } from "@coder/logger"
import * as fs from "fs-extra" import * as fs from "fs-extra"
import * as path from "path" import * as path from "path"
import { extend, paths } from "./util" import { Route } from "./http"
import { logger } from "@coder/logger" import { paths } from "./util"
export type Settings = { [key: string]: Settings | string | boolean | number } export type Settings = { [key: string]: Settings | string | boolean | number }
@@ -29,11 +30,13 @@ export class SettingsProvider<T> {
/** /**
* Write settings combined with current settings. On failure log a warning. * Write settings combined with current settings. On failure log a warning.
* Objects will be merged and everything else will be replaced. * Settings will be merged shallowly.
*/ */
public async write(settings: Partial<T>): Promise<void> { public async write(settings: Partial<T>): Promise<void> {
try { try {
await fs.writeFile(this.settingsPath, JSON.stringify(extend(await this.read(), settings), null, 2)) const oldSettings = await this.read()
const nextSettings = { ...oldSettings, ...settings }
await fs.writeFile(this.settingsPath, JSON.stringify(nextSettings, null, 2))
} catch (error) { } catch (error) {
logger.warn(error.message) logger.warn(error.message)
} }
@@ -55,6 +58,7 @@ export interface CoderSettings extends UpdateSettings {
url: string url: string
workspace: boolean workspace: boolean
} }
query: Route["query"]
} }
/** /**

View File

@@ -1,10 +1,10 @@
import * as cp from "child_process" import * as cp from "child_process"
import * as crypto from "crypto" import * as crypto from "crypto"
import envPaths from "env-paths"
import * as fs from "fs-extra" import * as fs from "fs-extra"
import * as os from "os" import * as os from "os"
import * as path from "path" import * as path from "path"
import * as util from "util" import * as util from "util"
import envPaths from "env-paths"
import xdgBasedir from "xdg-basedir" import xdgBasedir from "xdg-basedir"
export const tmpdir = path.join(os.tmpdir(), "code-server") export const tmpdir = path.join(os.tmpdir(), "code-server")
@@ -199,25 +199,6 @@ export const isObject = <T extends object>(obj: T): obj is T => {
return !Array.isArray(obj) && typeof obj === "object" && obj !== null return !Array.isArray(obj) && typeof obj === "object" && obj !== null
} }
/**
* Extend a with b and return a new object. Properties with objects will be
* recursively merged while all other properties are just overwritten.
*/
export function extend<A, B>(a: A, b: B): A & B
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function extend(...args: any[]): any {
const c = {} as any // eslint-disable-line @typescript-eslint/no-explicit-any
for (const obj of args) {
if (!isObject(obj)) {
continue
}
for (const key in obj) {
c[key] = isObject(obj[key]) ? extend(c[key], obj[key]) : obj[key]
}
}
return c
}
/** /**
* Taken from vs/base/common/charCode.ts. Copied for now instead of importing so * Taken from vs/base/common/charCode.ts. Copied for now instead of importing so
* we don't have to set up a `vs` alias to be able to import with types (since * we don't have to set up a `vs` alias to be able to import with types (since

View File

@@ -1,6 +1,9 @@
import { logger, field } from "@coder/logger" import { field, logger } from "@coder/logger"
import * as cp from "child_process" import * as cp from "child_process"
import * as path from "path"
import * as rfs from "rotating-file-stream"
import { Emitter } from "../common/emitter" import { Emitter } from "../common/emitter"
import { paths } from "./util"
interface HandshakeMessage { interface HandshakeMessage {
type: "handshake" type: "handshake"
@@ -140,8 +143,17 @@ export interface WrapperOptions {
export class WrapperProcess { export class WrapperProcess {
private process?: cp.ChildProcess private process?: cp.ChildProcess
private started?: Promise<void> private started?: Promise<void>
private readonly logStdoutStream: rfs.RotatingFileStream
private readonly logStderrStream: rfs.RotatingFileStream
public constructor(private currentVersion: string, private readonly options?: WrapperOptions) { public constructor(private currentVersion: string, private readonly options?: WrapperOptions) {
const opts = {
size: "10M",
maxFiles: 10,
}
this.logStdoutStream = rfs.createStream(path.join(paths.data, "coder-logs", "code-server-stdout.log"), opts)
this.logStderrStream = rfs.createStream(path.join(paths.data, "coder-logs", "code-server-stderr.log"), opts)
ipcMain().onDispose(() => { ipcMain().onDispose(() => {
if (this.process) { if (this.process) {
this.process.removeAllListeners() this.process.removeAllListeners()
@@ -176,6 +188,15 @@ export class WrapperProcess {
public start(): Promise<void> { public start(): Promise<void> {
if (!this.started) { if (!this.started) {
this.started = this.spawn().then((child) => { this.started = this.spawn().then((child) => {
// Log both to stdout and to the log directory.
if (child.stdout) {
child.stdout.pipe(this.logStdoutStream)
child.stdout.pipe(process.stdout)
}
if (child.stderr) {
child.stderr.pipe(this.logStderrStream)
child.stderr.pipe(process.stderr)
}
logger.debug(`spawned inner process ${child.pid}`) logger.debug(`spawned inner process ${child.pid}`)
ipcMain() ipcMain()
.handshake(child) .handshake(child)
@@ -205,7 +226,7 @@ export class WrapperProcess {
CODE_SERVER_PARENT_PID: process.pid.toString(), CODE_SERVER_PARENT_PID: process.pid.toString(),
NODE_OPTIONS: nodeOptions, NODE_OPTIONS: nodeOptions,
}, },
stdio: ["inherit", "inherit", "inherit", "ipc"], stdio: ["ipc"],
}) })
} }
} }

View File

@@ -6,8 +6,8 @@ import * as net from "net"
import * as path from "path" import * as path from "path"
import * as tls from "tls" import * as tls from "tls"
import { Emitter } from "../src/common/emitter" import { Emitter } from "../src/common/emitter"
import { generateCertificate, tmpdir } from "../src/node/util"
import { SocketProxyProvider } from "../src/node/socket" import { SocketProxyProvider } from "../src/node/socket"
import { generateCertificate, tmpdir } from "../src/node/util"
describe("SocketProxyProvider", () => { describe("SocketProxyProvider", () => {
const provider = new SocketProxyProvider() const provider = new SocketProxyProvider()

View File

@@ -2,40 +2,33 @@ import * as assert from "assert"
import * as fs from "fs-extra" import * as fs from "fs-extra"
import * as http from "http" import * as http from "http"
import * as path from "path" import * as path from "path"
import * as tar from "tar-fs"
import * as zlib from "zlib"
import { LatestResponse, UpdateHttpProvider } from "../src/node/app/update" import { LatestResponse, UpdateHttpProvider } from "../src/node/app/update"
import { AuthType } from "../src/node/http" import { AuthType } from "../src/node/http"
import { SettingsProvider, UpdateSettings } from "../src/node/settings" import { SettingsProvider, UpdateSettings } from "../src/node/settings"
import { tmpdir } from "../src/node/util" import { tmpdir } from "../src/node/util"
describe("update", () => { describe("update", () => {
const archivePath = path.join(tmpdir, "tests/updates/code-server-loose-source")
let version = "1.0.0" let version = "1.0.0"
let spy: string[] = [] let spy: string[] = []
const server = http.createServer((request: http.IncomingMessage, response: http.ServerResponse) => { const server = http.createServer((request: http.IncomingMessage, response: http.ServerResponse) => {
if (!request.url) { if (!request.url) {
throw new Error("no url") throw new Error("no url")
} }
spy.push(request.url) spy.push(request.url)
response.writeHead(200)
// Return the latest version.
if (request.url === "/latest") { if (request.url === "/latest") {
const latest: LatestResponse = { const latest: LatestResponse = {
name: version, name: version,
} }
response.writeHead(200)
return response.end(JSON.stringify(latest)) return response.end(JSON.stringify(latest))
} }
const path = archivePath + (request.url.endsWith(".tar.gz") ? ".tar.gz" : ".zip") // Anything else is a 404.
response.writeHead(404)
const stream = fs.createReadStream(path) response.end("not found")
stream.on("error", (error: NodeJS.ErrnoException) => {
response.writeHead(500)
response.end(error.message)
})
response.writeHead(200)
stream.on("close", () => response.end())
stream.pipe(response)
}) })
const jsonPath = path.join(tmpdir, "tests/updates/update.json") const jsonPath = path.join(tmpdir, "tests/updates/update.json")
@@ -51,12 +44,10 @@ describe("update", () => {
_provider = new UpdateHttpProvider( _provider = new UpdateHttpProvider(
{ {
auth: AuthType.None, auth: AuthType.None,
base: "/update",
commit: "test", commit: "test",
}, },
true, true,
`http://${address.address}:${address.port}/latest`, `http://${address.address}:${address.port}/latest`,
`http://${address.address}:${address.port}/download/{{VERSION}}/{{RELEASE_NAME}}`,
settings, settings,
) )
} }
@@ -72,32 +63,8 @@ describe("update", () => {
host: "localhost", host: "localhost",
}) })
}) })
const p = provider()
const archiveName = (await p.getReleaseName({ version: "9999999.99999.9999", checked: 0 })).replace(
/.tar.gz$|.zip$/,
"",
)
await fs.remove(path.join(tmpdir, "tests/updates")) await fs.remove(path.join(tmpdir, "tests/updates"))
await fs.mkdirp(path.join(archivePath, archiveName)) await fs.mkdirp(path.join(tmpdir, "tests/updates"))
await Promise.all([
fs.writeFile(path.join(archivePath, archiveName, "code-server"), `console.log("UPDATED")`),
fs.writeFile(path.join(archivePath, archiveName, "node"), `NODE BINARY`),
])
await new Promise((resolve, reject) => {
const write = fs.createWriteStream(archivePath + ".tar.gz")
const compress = zlib.createGzip()
compress.pipe(write)
compress.on("error", (error) => compress.destroy(error))
compress.on("close", () => write.end())
tar.pack(archivePath).pipe(compress)
write.on("close", reject)
write.on("finish", () => {
resolve()
})
})
}) })
after(() => { after(() => {
@@ -185,53 +152,15 @@ describe("update", () => {
assert.equal(p.isLatestVersion(update), true) assert.equal(p.isLatestVersion(update), true)
}) })
it("should download and apply an update", async () => {
version = "9999999.99999.9999"
const p = provider()
const update = await p.getUpdate(true)
// Create an existing version.
const destination = path.join(tmpdir, "tests/updates/code-server")
await fs.mkdirp(destination)
const entry = path.join(destination, "code-server")
await fs.writeFile(entry, `console.log("OLD")`)
assert.equal(`console.log("OLD")`, await fs.readFile(entry, "utf8"))
// Updating should replace the existing version.
await p.downloadAndApplyUpdate(update, destination)
assert.equal(`console.log("UPDATED")`, await fs.readFile(entry, "utf8"))
// There should be a backup.
const dir = (await fs.readdir(path.join(tmpdir, "tests/updates"))).filter((dir) => {
return dir.startsWith("code-server.")
})
assert.equal(dir.length, 1)
assert.equal(
`console.log("OLD")`,
await fs.readFile(path.join(tmpdir, "tests/updates", dir[0], "code-server"), "utf8"),
)
const archiveName = await p.getReleaseName(update)
assert.deepEqual(spy, ["/latest", `/download/${version}/${archiveName}`])
})
it("should not reject if unable to fetch", async () => { it("should not reject if unable to fetch", async () => {
const options = { const options = {
auth: AuthType.None, auth: AuthType.None,
base: "/update",
commit: "test", commit: "test",
} }
let provider = new UpdateHttpProvider(options, true, "invalid", "invalid", settings) let provider = new UpdateHttpProvider(options, true, "invalid", settings)
await assert.doesNotReject(() => provider.getUpdate(true)) await assert.doesNotReject(() => provider.getUpdate(true))
provider = new UpdateHttpProvider( provider = new UpdateHttpProvider(options, true, "http://probably.invalid.dev.localhost/latest", settings)
options,
true,
"http://probably.invalid.dev.localhost/latest",
"http://probably.invalid.dev.localhost/download",
settings,
)
await assert.doesNotReject(() => provider.getUpdate(true)) await assert.doesNotReject(() => provider.getUpdate(true))
}) })
}) })

View File

@@ -1,43 +1,7 @@
import * as assert from "assert" import * as assert from "assert"
import { normalize } from "../src/common/util" import { normalize } from "../src/common/util"
import { extend } from "../src/node/util"
describe("util", () => { describe("util", () => {
describe("extend", () => {
it("should extend", () => {
const a = { foo: { bar: 0, baz: 2 }, garply: 4, waldo: 6 }
const b = { foo: { bar: 1, qux: 3 }, garply: "5", fred: 7 }
const extended = extend(a, b)
assert.deepEqual(extended, {
foo: { bar: 1, baz: 2, qux: 3 },
garply: "5",
waldo: 6,
fred: 7,
})
})
it("should make deep copies of the original objects", () => {
const a = { foo: 0, bar: { frobnozzle: 2 }, mumble: { qux: { thud: 4 } } }
const b = { foo: 1, bar: { chad: 3 } }
const extended = extend(a, b)
assert.notEqual(a.bar, extended.bar)
assert.notEqual(b.bar, extended.bar)
assert.notEqual(a.mumble, extended.mumble)
assert.notEqual(a.mumble.qux, extended.mumble.qux)
})
it("should handle mismatch in type", () => {
const a = { foo: { bar: 0, baz: 2, qux: { mumble: 11 } }, garply: 4, waldo: { thud: 10 } }
const b = { foo: { bar: [1], baz: { plugh: 8 }, qux: 12 }, garply: { nox: 9 }, waldo: 7 }
const extended = extend(a, b)
assert.deepEqual(extended, {
foo: { bar: [1], baz: { plugh: 8 }, qux: 12 },
garply: { nox: 9 },
waldo: 7,
})
})
})
describe("normalize", () => { describe("normalize", () => {
it("should remove multiple slashes", () => { it("should remove multiple slashes", () => {
assert.equal(normalize("//foo//bar//baz///mumble"), "/foo/bar/baz/mumble") assert.equal(normalize("//foo//bar//baz///mumble"), "/foo/bar/baz/mumble")

View File

@@ -8,17 +8,15 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"outDir": "./out", "outDir": "./out",
"allowJs": false,
"jsx": "react",
"declaration": true, "declaration": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"esModuleInterop": true, "esModuleInterop": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"sourceMap": true, "sourceMap": true,
"tsBuildInfoFile": "./.tsbuildinfo", "tsBuildInfoFile": "./.cache/tsbuildinfo",
"incremental": true, "incremental": true,
"rootDir": "./src", "rootDir": "./src",
"typeRoots": ["./node_modules/@types", "./typings"] "typeRoots": ["./node_modules/@types", "./typings"]
}, },
"include": ["./src/**/*.ts", "./src/**/*.tsx"] "include": ["./src/**/*.ts"]
} }

2093
yarn.lock

File diff suppressed because it is too large Load Diff