Compare commits

..

22 Commits
httperr ... 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
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
28 changed files with 281 additions and 257 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

@@ -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,11 +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/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

@@ -722,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..8fb2a87303 index 0000000000..3c0703b717
--- /dev/null --- /dev/null
+++ b/src/vs/server/browser/client.ts +++ b/src/vs/server/browser/client.ts
@@ -0,0 +1,208 @@ @@ -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';
@@ -761,31 +761,12 @@ index 0000000000..8fb2a87303
+}; +};
+ +
+/** +/**
+ * 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;
+ } + }
+}; +};
@@ -1306,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
+} +}
+ +

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,17 +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/register.ts"), path.join(this.rootPath, "src/browser/serviceWorker.ts")], [
path.join(this.rootPath, "src/browser/register.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

@@ -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

@@ -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/register.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

@@ -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/register.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

@@ -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

@@ -7,7 +7,7 @@ import "./pages/global.css"
import "./pages/login.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 || "/",

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,7 +9,7 @@ export enum HttpCode {
} }
export class HttpError extends Error { export class HttpError extends Error {
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
} }

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"
@@ -40,21 +43,28 @@ export const trimSlashes = (url: string): string => {
return url.replace(/^\/+|\/+$/g, "") 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
} }
@@ -68,15 +78,10 @@ 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))

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

@@ -200,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"],
}) })
} }

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

@@ -9,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}`)
@@ -77,6 +79,8 @@ const main = async (args: Args, cliArgs: Args, configArgs: Args): Promise<void>
httpServer.registerHttpProvider("/login", LoginHttpProvider, args.config!, envPassword) httpServer.registerHttpProvider("/login", LoginHttpProvider, args.config!, envPassword)
httpServer.registerHttpProvider("/static", StaticHttpProvider) httpServer.registerHttpProvider("/static", StaticHttpProvider)
await loadPlugins(httpServer, args)
ipcMain().onDispose(() => { ipcMain().onDispose(() => {
httpServer.dispose().then((errors) => { httpServer.dispose().then((errors) => {
errors.forEach((error) => logger.error(error.message)) errors.forEach((error) => logger.error(error.message))
@@ -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

@@ -209,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
@@ -235,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
} }
@@ -481,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"
@@ -664,7 +657,7 @@ 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)
} }
@@ -875,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
} }
@@ -884,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,8 +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 { logger } from "@coder/logger"
import { Route } from "./http" import { Route } from "./http"
import { paths } from "./util"
export type Settings = { [key: string]: Settings | string | boolean | number } export type Settings = { [key: string]: Settings | string | boolean | number }
@@ -30,12 +30,12 @@ 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.
* Settings can be shallow or deep merged. * Settings will be merged shallowly.
*/ */
public async write(settings: Partial<T>, shallow = true): Promise<void> { public async write(settings: Partial<T>): Promise<void> {
try { try {
const oldSettings = await this.read() const oldSettings = await this.read()
const nextSettings = shallow ? Object.assign({}, oldSettings, settings) : extend(oldSettings, settings) const nextSettings = { ...oldSettings, ...settings }
await fs.writeFile(this.settingsPath, JSON.stringify(nextSettings, null, 2)) await fs.writeFile(this.settingsPath, JSON.stringify(nextSettings, null, 2))
} catch (error) { } catch (error) {
logger.warn(error.message) logger.warn(error.message)

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

@@ -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

@@ -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"]
} }