Node and Rust are both installed together with a package manager.
- Node's package manager is called npm, its packages are called node modules and its official website is npmjs.com.
- Rust's package manager is called Cargo, its packages are called crates and its official website is crates.io.
If you followed my Setup and you use Node v10.14.2 your npm version is probably 6.4.1. You can check this by running the following command:
$ npm --version
6.4.1
If you want to update npm you can run this:
$ npm install -g npm
Let us check our installed Cargo version. I have 1.31.0:
$ cargo --version
cargo 1.31.0 (339d9f9c8 2018-11-16)
As you can see it prints the same version as our installed Rust compiler. It's best practice to update both tools in tandem with rustup
:
$ rustup update
The manifest file - the file which contains meta-data of your project like its name, its version, its dependencies and so on - is called package.json
in the Node world and Cargo.toml
in Rust. We'll now add manifest files to our "Hello World" examples, we created earlier.
Lets have a look at a typical package.json
without dependencies:
{
"name": "hello-world",
"version": "0.1.0",
"author": "John Doe <[email protected]> (https://github.com/john.doe)",
"contributors": [
"Jane Doe <[email protected]> (https://github.com/jane.doe)"
],
"private": true,
"description": "This is just a demo.",
"license": "MIT OR Apache-2.0",
"keywords": ["demo", "test"],
"homepage": "https://github.com/john.doe/hello-world",
"repository": {
"type": "git",
"url": "https://github.com/john.doe/hello-world"
},
"bugs": "https://github.com/john.doe/hello-world/issues"
}
The Cargo.toml
looks really similar (besides being a .toml
and not .json
):
[package]
name = "hello-world"
version = "0.1.0"
authors = ["John Doe <[email protected]>",
"Jane Doe <[email protected]>"]
publish = false
description = "This is just a demo."
license = "MIT OR Apache-2.0"
keywords = ["demo", "test"]
homepage = "https://github.com/john.doe/hello-world"
repository = "https://github.com/john.doe/hello-world"
documentation = "https://github.com/john.doe/hello-world"
So what have we here? Both manifest formats offer name
and version
fields which are mandatory. Adding the authors of a project is slightly different between the modules, but optional for both. npm assumes a main author
for every package and multiple contributors
while in Cargo you just fill an authors
array. The authors
field is actually mandatory for Cargo. As a value you use a string with the pattern name <email> (url)
in npm and name <email>
in Cargo. (Maybe (url)
will be added in the future, but currently it is not used by anyone in Cargo.) Note that <email>
and (url)
are optional and that name
doesn't have to be a person. You can just use you company name as well or something like my cool team
.
If you don't accidentally want to publish a module to a public repository you can do that with either "private": true
in npm or publish = false
in Cargo. You can optionally add a description
field to describe your project. (While you technically could use MarkDown in your descriptions, the support is spotty in both ecosystems and it isn't rendered properly most of the time.)
To add a single license you write "license": "MIT"
in npm and license = "MIT"
in Cargo. In both cases the value needs to be an SPDX license identifier. If you use multiple licences you can use an SPDX license expression like "license": "MIT OR Apache-2.0"
for npm or license = "MIT OR Apache-2.0"
for Cargo.
You can also optionally add multiple keywords
, so your package can be found more easily in the official repositories.
You can add a link to your homepage
and repository
in both files (with a slightly different format for repository
). npm allows you to add a link to reports bugs
while Cargo allows you to add a link to find documentation
.
Cargo can be used to build your Rust project and you can add custom build scripts to npm as well. (Remember that you don't need a build step in the Node ecosystem, but if you rely on something like TypeScript it is needed. I'll show this in more in-depth when I introduce TypeScript to our Node projects.)
For now I just added a main
and scripts.start
field to our package.json
:
{
// ...your previous code
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
}
}
A main
field points to your packages entry file. This is the file that will be loaded, if someone requires your package. scripts.start
is a convention to point to the file which should be loaded, if you want to run your package by calling $ npm start
:
$ npm start
> [email protected] start /Users/pipo/workspace/rust-for-node-developers/package-manager/meta-data/node
> node src
Hello world!
To ignore the npm output use -s
(for --silent
):
$ npm -s start
Hello world!
In this case the entry file to our package specified in main
and the file which should be run if you call $ npm start
point to the same file, but this doesn't have to be the case. Additionally you could specify multiple executable files in a field called bin
.
Cargo on the other hand will look for a src/main.rs
file to build and/or run and if it finds a src/lib.rs
file, it will build a library which than can be required by a different crate.
You run your Rust project with Cargo like this:
$ cargo run
Compiling hello-world v0.1.0 (/Users/pipo/workspace/rust-for-node-developers/package-manager/meta-data/rust)
Finished dev [unoptimized + debuginfo] target(s) in 0.61s
Running `target/debug/hello-world`
Hello world!
To ignore the Cargo output use -q
(for --quiet
):
$ cargo -q run
Hello world!
You'll see that Cargo created a new file in your directory: a Cargo.lock
. (It also placed your compiled program in a target
directory.) The Cargo.lock
file basically works like a package-lock.json
in the Node world (or a yarn.lock
, if you use yarn instead of npm), but is also generated during builds. Just to be complete let us generate a package-lock.json
as well:
$ npm install
npm notice created a lockfile as package-lock.json. You should commit this file.
up to date in 0.924s
found 0 vulnerabilities
This should be your package-lock.json
:
{
"name": "hello-world",
"version": "0.1.0",
"lockfileVersion": 1
}
This should be your Cargo.lock
:
[[package]]
name = "hello-world"
version = "0.1.0"
Both files become more interesting if you use dependencies in your project to ensure everyone uses the same dependencies (and dependencies of dependencies) at any time.
Before we move on let us make a slight adjustment to our Cargo.toml
by adding the line edition = "2018"
. This will add support for Rust 2018 to our package. Editions are a feature which allow us to make backwards incompatible changes in Rust without introducing new major versions. You basically opt-in into new language features per package and your dependencies can be a mix of different editions. Currently there are two different editions available: Rust 2015 (which is the default) and Rust 2018 (which is the newest).
Before we learn how to install and use dependencies we will actually publish a package that we can require afterwards. It will just export a Hello world!
string. I call both packages rfnd-hello-world
(with rfnd
as an abbreviation for "Rust for Node developers"). npm offers namespacing of modules called scoped packages. If I'd have used that feature our module could have looked like this: @rfnd/hello-world
. Cargo doesn't support namespacing and this is an intended limitation. By the way... even if snake_case
is preferred for files and directories in Rust the module names in Cargo should use kebab-case
by convention. This is probably used most of time in npm world, too.
I'll introduce TypeScript for our Node module in this step. It isn't that necessary currently, but it'll simplify some comparisons between Node and Rust in the next chapters when I use types or modern language features like ES2015 modules. First we need to install TypeScript as a devDependency
, which is a dependency we only need for development, but not for our package itself at runtime:
$ npm install --save-dev typescript
To build the project we need to call the TypeScript compiler (tsc
) by adding a new build
field in the scripts
object of the package.json
. We also add a prepublishOnly
entry which always runs the build process before we'll publish our module:
{
"scripts": {
"build": "tsc --build src",
"prepublishOnly": "npm run build"
}
}
By using --build src
the TypeScript will look for a tsconfig.json
in the src/
directory which configures the actual output. It looks like this:
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"outDir": "../dist",
"sourceMap": true,
"declarationMap": true
}
}
Note that we'll generate CommonJS modules (because this is a Node project), it will generate declaration
files (so other TypeScript projects know our types and interfaces even when they use the generated JavaScript files), all JavaScript and declaration files will be placed in a dist
folder and finally we generate Source Maps to map the generated JavaScript back to the original TypeScript code (useful for debugging).
This also means that the main
field in our package.json
now points to dist/index.js
- our compiled JavaScript code. And we also add a typings
field which shows other modules where our generated declarations are stored.
{
"main": "dist/index.js",
"typings": "dist/index.d.ts"
}
Note that we don't want to commit our node_modules
and dist
to our repository, because these directories contain external or generated code. But be warned! If you place these directories in a .gitignore
npm will not include them in our published package. This okay for node_modules
(which are never included anyway), but a package without dist
is pointless. You'll actually need to add an empty .npmignore
, so npm ignores the .gitignore
. (A little bit tricky, I know.) You can use the .npmignore
to ignore files and directories which are committed in your repository, but shouldn't be included in your published package. In our case it'd be fine to include everything. As an alternative you could also explicitly list all files which should be included in a files
field in your package.json
.
With this setup aside this is our actual package under index.ts
:
export const HELLO_WORLD = 'Hello world!';
We export
a const
with the value 'Hello world!'
. This is ES2015 module syntax and we write our exported variable name in UPPER_CASES
which is a common convention for constants. Call $ npm run build
to build your project.
This is how our generated dist/index.js
looks:
'use strict';
exports.__esModule = true;
exports.HELLO_WORLD = 'Hello world!';
//# sourceMappingURL=index.js.map
Nothing fancy. Basically the same code in a different module syntax. The second line tells other tools that it was originaly an ES2015 module. The last line links our file to the corresponding Source Map.
The generated declaration file dist/index.d.ts
is also worth a look:
export declare const HELLO_WORLD = 'Hello world!';
//# sourceMappingURL=index.d.ts.map
You see that TypeScript could infer the type of HELLO_WORLD
as a 'Hello world!'
. This is a value type which is in this case a special variant of the type string
with the concrete value 'Hello world!'
.
We didn't need to tell TypeScript the type explicitly, but we could have done that. It would have looked like that:
export const HELLO_WORLD: 'Hello world!' = 'Hello world!';
Or like this, if we'd just want to tell others that it is a string:
export const HELLO_WORLD: string = 'Hello world!';
Great. This is our module. Now it needs to be published. You need to create an account at npmjs.com. If you have done that you'll get a profile like this. Now call $ npm login
and enter your credentials from your new account. After that you can just call $ npm publish
;
$ npm publish
> [email protected] prepublishOnly .
> npm run build
> [email protected] build /Users/pipo/workspace/rust-for-node-developers/package-manager/publishing/node
> tsc --build src
# some output from npm notice...
+ [email protected]
Congratulations! 🎉 You successfully created a package which can be seen here.
Time for Rust! We first create a .gitignore
with the following content:
Cargo.lock
target
As pointed out earlier Cargo.lock
behaves similar to package-lock.json
, but while the package-lock.json
can always be committed into your version control the Cargo.lock
should only be committed for binary projects, not libraries. Npm ignores the package-lock.json
in libraries, but Cargo doesn't do the same for Cargo.lock
.
The target
directory will contain generated code, so it is also ignored.
Actually this is all the setup we need. Now dive into our package (living in src/lib.rs
, because this will be a library):
pub const HELLO_WORLD: &str = "Hello world!";
As you can see this line of code in Rust is really similar to our TypeScript code (when we excplicitly set the type to string
) which looked like this:
export const HELLO_WORLD: string = 'Hello world!';
Let's go through the Rust line of code word for word:
pub
makes our variable public - very much likeexport
in JavaScript, so it can be used by other packages.const
in Rust is different thanconst
in JavaScript though. In Rust this is a real constant - a value which can't be changed.- In JavaScript it is a constant binding which means we can't assign another value to the same name (in this case our variable name is
HELLO_WORLD
). But the value itself can be changed, if it is a non-primitive value. (E.g.const a = { b: 1 }; a.b = 2;
is possible.)
- In JavaScript it is a constant binding which means we can't assign another value to the same name (in this case our variable name is
- Unlike TypeScript we need to declare the type of
HELLO_WORLD
here by adding&str
or we'll get compiler errors. Rust also supports type inferring, butconst
always requires an explicit type annotation.&str
is pronounced as string slice (and as a reminder"Hello world!"
is pronounced as a string literal).- Rust actually has another String type called just
String
. A&str
has a fixed size and cannot be mutated while aString
is heap-allocated and has a dynamic size. A&str
can be easily converted to aString
with theto_string
method like this:"Hello world!".to_string();
. We'll see more of that in later examples, but you can already see methods can be invoked in the same way as we do in JavaScript and that built-in types come with a couple of built-in methods (like'hello'.toUpperCase()
in JavaScript for example).
We only need to publish our new crate now. You need to login on crates.io/ with your GitHub account to do so. If you've done that visit your account settings to get your API key. Than call cargo login
and pass your API key:
$ cargo login <api-key>
You can inspect what will be published by packaging your Crate locally like this:
$ cargo package
Much like npm Cargo ignores all your directories and files in your .gitignore
, too. That is fine. We don't need to ignore more files (or less) in this case. (If you do need to change that, you can modify your Cargo.toml
as explained in the documentation.)
Now we only need to publish the crate like this:
$ cargo publish
Updating crates.io index
Packaging rfnd-hello-world v1.0.1 (/Users/pipo/workspace/rust-for-node-developers/package-manager/publishing/rust)
Verifying rfnd-hello-world v1.0.1 (/Users/pipo/workspace/rust-for-node-developers/package-manager/publishing/rust)
Compiling rfnd-hello-world v1.0.1 (/Users/pipo/workspace/rust-for-node-developers/package-manager/publishing/rust/target/package/rfnd-hello-world-1.0.1)
Finished dev [unoptimized + debuginfo] target(s) in 1.48s
Uploading rfnd-hello-world v1.0.1 (/Users/pipo/workspace/rust-for-node-developers/package-manager/publishing/rust)
Awesome! Your crate is now published and can be seen here.
Remember that you can publish your package in the same version only once. This is true for Cargo and npm as well. To publish your package again with changes you need to change the version as well. The quickest way which doesn't introduce additional tooling is by just changing the value of version
in package.json
or Cargo.toml
manually. Both communities follow SemVer-style versioning (more or less).
This is probably the minimum you need to know to get started in publishing your own packages, but I only scratched the surface. Have a look at the npm documentation and Cargo documentation to learn more.
Now that we published two packages we can try to require them in other projects as dependencies.
Let us start with Node again to show you how using dependencies work. To be honest... we already used a dependency, right? TypeScript. We added it to the devDependencies
and use it in every example now:
$ npm install --save-dev typescript
devDependencies
are only needed when we develop our Node application, but not at runtime. We use our recently published package as a real dependency
. Install it like this:
$ npm install --save rfnd-hello-world
You should see the following dependencies in your package.json
:
{
"devDependencies": {
"typescript": "^3.2.2"
},
"dependencies": {
"rfnd-hello-world": "^1.0.1"
}
}
We should also change our start
script so it behaves similar to $ cargo run
- build the project and run it:
{
"scripts": {
"start": "npm run build && node dist",
"build": "tsc --build src"
}
}
The final package.json
looks pretty much like our previous example, just with less meta data. I'll show it to you, so we are on the same page:
{
"name": "use-hello-world",
"version": "0.1.0",
"private": true,
"main": "dist/index.js",
"typings": "dist/index.d.ts",
"scripts": {
"start": "npm run build && node dist",
"build": "tsc --build src"
},
"devDependencies": {
"typescript": "^3.2.2"
},
"dependencies": {
"rfnd-hello-world": "^1.0.1"
}
}
The tsconfig.json
is just copy and pasted without modifications.
We installed our dependencies, now we can use them like this:
import { HELLO_WORLD } from 'rfnd-hello-world';
console.log(`Required "${HELLO_WORLD}".`);
Let's run our example:
$ npm start
> [email protected] start /Users/pipo/workspace/rust-for-node-developers/package-manager/dependencies/node
> npm run build && node dist
> [email protected] build /Users/pipo/workspace/rust-for-node-developers/package-manager/dependencies/node
> tsc --build src
Required "Hello world!".
Good. Now we switch to Rust. We can't add dependencies to our project with Cargo without additional tooling. That's why we need to add it to our Cargo.toml
manually in a section called [dependencies]
. (You can watch this issue about adding a $ cargo add <package-name>
command which will work similar to $ npm install --save <package-name>
.)
[dependencies]
rfnd-hello-world = "1.0.1"
The crate will be automatically fetched as soon as we compile our program. Note that using 1.0.1
actually translates to ^1.0.1
! If you want a very specific version you should use =1.0.1
.
This is how our src/main.rs
looks like:
use rfnd_hello_world::HELLO_WORLD;
fn main() {
println!("Required: {}.", HELLO_WORLD);
}
Note that even though our external crate is called rfnd-hello-world
we access it with rfnd_hello_world
. Aside from the import we do with the use
keyword, you can see how the string interpolation works with the println!()
macro where {}
is a placeholder and we pass the value as the second parameter. (Printing to the terminal can be actually quite complex. Read this article to learn more.) In case you didn't know: console.log()
in Node can behave quite similar. We could rewrite console.log(`Required "${HELLO_WORLD}".`);
to console.log('Required "%s".', HELLO_WORLD);
. Try it!
As we use HELLO_WORLD
just a single time we could also have written the example like this:
fn main() {
println!("Required: {}.", rfnd_hello_world::HELLO_WORLD);
}
If rfnd_hello_world
would expose more than one constant we can use a syntax similar to ES2015 destructing.
use rfnd_hello_world::{HELLO_WORLD, SOME_OTHER_VALUE};
fn main() {
println!("Required: {}.", HELLO_WORLD);
println!("Also: {}.", SOME_OTHER_VALUE);
}
Nice. Now test your programm:
$ cargo run
Updating registry `https://github.com/rust-lang/crates.io-index`
Compiling use-hello-world v0.1.0 (file:///Users/donaldpipowitch/Workspace/rust-for-node-developers/package-manager/dependencies/rust)
Running `target/debug/use-hello-world`
Required "Hello world!".
It works! 🎉
To summarize: use rfnd_hello_world::HELLO_WORLD;
(or use rfnd_hello_world::{HELLO_WORLD};
for multiple imports) works similar to import { HELLO_WORLD } from 'rfnd-hello-world';
, but we can also inline the "import" as println!("Required: {}.", rfnd_hello_world::HELLO_WORLD);
which would be very similar to console.log(`Required "${require('rfnd-hello-world').HELLO_WORLD}".`);
.
← prev "Hello World" | next →