How JavaScript works: introduction to Deno
This is post # 28 of the series, dedicated to exploring JavaScript and its building components. In the process of identifying and describing the core elements, we also share some rules of thumb we use when building SessionStack, a JavaScript tool for developers to identify, visualize, and reproduce web app bugs through pixel-perfect session replay.
Overview
Deno is a secure runtime for running JavaScript and Typescript applications. In this article, we will look at the origins of Deno, compare it to Node.js, and explore various aspects like modules, packages, async support, Typescript support, security, and tooling. We will also look under the hood of Deno and how it’s implemented. But, first, let’s see Deno in action.
Lightning-quick Deno Demo
Installing Deno on the mac is as easy as:
brew install deno
For other operating systems, follow the instructions here:
https://deno.land/#installation
Deno can run your scripts, but also has a REPL.
Given the following file yeah.ts:
Deno can run it using:
$ deno run -q ./yeah.ts
Yeah, it works!!!
Let’s do the same thing in the REPL
$ deno
Deno 1.9.0
exit using ctrl+d or close()
> console.log(‘Yeah, it works!’)
Yeah, it works!
undefined
Deno has many other sub-commands besides run. Type deno -h to see them.
Now that we saw some code and typed some commands let’s take a step back and talk about where Deno came from.
Origins of Deno
Deno was announced in Ryan Dahl’s (Node.js creator) talk “10 Things I Regret About Node.js” at the JSConf EU 2018. This is pretty cool that a creator gets to see his original project skyrockets in popularity, observe it being used in the field and gets to learn from its mistakes. The typical scenario is that creators try to improve and refactor their original project. However, Ryan Dahl wanted to implement some radical changes that just couldn’t be done with Node.js because it would break compatibility.
A total rewrite is obviously a huge risk, but Deno crossed the chasm and has great momentum.
Also, remember that Deno is still very young; the first commit was on May 13th, 2018, and version 1.0 was released exactly two years later on May 13th, 2020.
Let’s see how Deno compares to Node.js
Deno vs. Node.js
Here are the main differences between Node.js and Deno. Later, we’ll discuss the internals too, but here we focus on the functional and developer experience differences.
built-in package management vs npm
Node.js relies on npm for package management. Deno on the other hand follows the example of Go and Rust and can import packages from anywhere by URL.
ES modules vs. CommonJS modules
Node uses the CommonJS standard:
const module = require(‘module-name’)
Deno uses the standard EcmaScript modules:
import module from ‘https://some-repo/module-name.ts'
Note that Deno requires fully qualified module names including the extension.
Permission-based access vs. full-access
Node gives you full access to the environment, file system, and network. This is a serious security vulnerability. There were several attacks where malicious npm modules took advantage of this and gained access to these resources.
Deno requires explicit permissions, so it limits the exposure to bad actors.
Built-in typescript compiler vs. external support
Node doesn’t support Typescript directly, You need to use a heavyweight and always changing toolchain with bundlers, transpilers, etc.
Deno has first-class support for Typescript, which makes it much more streamlined to work with.
Promises vs. callbacks
Node uses non-blocking I/O and requires callbacks to get notified when I/O operations are complete.
Deno instead uses the modern async/await paradigm, which hides the complexities involved with callback chains and makes the code much cleaner and easier to reason about.
Die on error vs. uncaught exceptions
In Node you can have a global handler for all uncaught exceptions:
process.on(‘uncaughtException’, function (err) {
console.log(‘ignoring…’);
})
In Deno, your program will die if you don’t catch an exception. This a major design decision.
Let’s take a closer look at Deno’s primary features.
Modules and package management
With Deno you import modules by URL. There is no need for package.json and node_modules. That said, there is a cache, so you download packages and modules just once. Here is an example:
If your arithmetic skills are on point, you’ll notice that the assertion is incorrect (2 + 2 is actually 4). Let’s see if Deno thinks so as well:
$ deno run ./assert.ts
error: Uncaught AssertionError: Values are not equal:
[Diff] Actual / Expected
- 4
+ 5
throw new AssertionError(message);
^
at assertEquals (https://deno.land/std@0.93.0/testing/asserts.ts:219:9)
at file:///Users/gigi.sayfan/git/deno_test/assert.ts:3:1
Yep. Let’s fix it.
Now, it succeeds:
$ deno run ./assert.ts
Check file:///Users/gigi.sayfan/git/deno_test/assert.ts
success!
OK, back to packages and imports. Consider this line:
import { assertEquals } from “https://deno.land/std@0.93.0/testing/asserts.ts";
Here Deno imports the assertEquals symbol (which happens to be a function in the Deno standard library) directly from a URL. Note that the URL contains version information (std@0.93.0), so it’s easy to support multiple versions of the same package.
Deno maintains a curated list of packages in https://deno.land, but you can import packages from any URL.
To demonstrate this try replacing the import statement with the following equivalent import statement that imports using the Github URL that hosts Deno’s standard library:
import { assertEquals } from “https://raw.githubusercontent.com/denoland/deno_std/main/testing/asserts.ts"
Async support
Deno returns promises from its asynchronous APIs. This means that you can run your asynchronous operation and wait for the result without orchestrating a spaghetti ball of callback functions.
Here is a crazy example:
What’s going on here? We use the Deno.run() API to launch a sub-process. The subprocess we launch is another instance of Deno and we evaluate the expression console.log(2+3), which of course prints 5 to the console.
In this case, the sub-process returns almost immediately, but for long-running processes, we want to wait for completion without blocking the rest of our problem, which is why we await the promise.status().
Deno and Typescript
Deno has built-in support for Typescript. That means that unlike Node or web applications running in the browser there is no need for heavyweight and non-standard toolchain. Deno contains a Typescript compiler and it will transpile your Typescript code to JavaScript and execute it later on the V8 runtime.
Deno caches transpiled Typescript modules, so as long as the Typescript file doesn’t change it will not be transpiled again.
To see the cache location check the emitted modules cache:
$ deno info
DENO_DIR location: “/Users/gigi.sayfan/Library/Caches/deno”
Remote modules cache: “/Users/gigi.sayfan/Library/Caches/deno/deps”
Emitted modules cache: “/Users/gigi.sayfan/Library/Caches/deno/gen”
Language server registries cache: “/Users/gigi.sayfan/Library/Caches/deno/registries”
Security
One of the primary reasons for creating Deno is security. Deno was designed to be secure from the ground up and users control what level of access each program has.
Resources like the network, the environment, and the file system are inaccessible by default. For example, let’s run a program that tries to write a file and see what we get.
Let’s save the following snippet to a file called write_file.ts.
Then try to run it:
$ deno run write_file.ts
Check file:///Users/gigi.sayfan/git/deno_test/write_file.ts
error: Uncaught PermissionDenied: Requires write access to “1.txt”, run again with the — allow-write flag
Deno.writeTextFileSync(‘data.txt’, ‘some data’)
^
at unwrapOpResult (deno:core/core.js:100:13)
at Object.opSync (deno:core/core.js:114:12)
at openSync (deno:runtime/js/40_files.js:32:22)
at writeFileSync (deno:runtime/js/40_write_file.js:24:18)
at Object.writeTextFileSync (deno:runtime/js/40_write_file.js:82:12)
at file:///Users/gigi.sayfan/git/deno_test/write_file.ts:1:6
As expected we got a permission error, with a useful message telling us what flag we need to add. Let’s run the program again with the proper permission flag:
$ deno run — allow-write write_file.ts
Check file:///Users/gigi.sayfan/git/deno_test/write_file.ts
$ cat data.txt
some data
Note that the Deno REPL has all the permissions. So, be careful if you run untrusted code in an interactive Deno session.
Tooling
Deno puts a lot of emphasis on the developer experience. It provides a lot of tools out of the box. I like this batteries-included approach. Let’s review quickly the various tools Deno provides.
Formatting
Formatting is arguably not essential and is just nice to have. But, the developer community learned over time that a lot of energy was poured over white space and brace placement arguments and flame wars.
Deno takes a page from Go and Rust and provides a `deno fmt` command. Let’s see what official deno formatting looks like.
Consider the following file fmt-test.ts:
It is a valid Typescript program. But, it’s not formatted very well.
Let’s run it through deno fmt:
$ cat fmt_test.ts | deno fmt -
Here is the result:
Deno fmt put the opening brace of the function on the same line as the function declaration, indented everything with two spaces, converted single quotes to double quotes, put spaces around the “+” operator, and added semicolons at the end of each line.
Testing
Testing is an essential part of programming. Deno doesn’t just rely on the community to come up with test frameworks and comes geared up with its own assertions module you can use to write tests. Let’s give it a try.
In the test-test.ts file I define a function called is_palindrome() that checks if a string is a palindrome or not (ignoring spaces) and then a few tests, The first two tests should pass and the third one should fail:
To run the test I use the deno test command:
$ deno test test_test.ts
Check file:///Users/gigi.sayfan/git/deno_test/$deno$test.ts
running 3 tests
test Palindrome 1 — success … ok (1ms)
test Palindrome 2 — success … ok (1ms)
test Palindrome 3 — fail … FAILED (2ms)
failures:
Palindrome 3 — fail
AssertionError: fail!
at assert (https://deno.land/std@0.95.0/testing/asserts.ts:178:11)
at file:///Users/gigi.sayfan/git/deno_test/test_test.ts:19:3
at asyncOpSanitizer (deno:runtime/js/40_testing.js:37:15)
at resourceSanitizer (deno:runtime/js/40_testing.js:73:13)
at Object.exitSanitizer [as fn] (deno:runtime/js/40_testing.js:100:15)
at TestRunner.[Symbol.asyncIterator] (deno:runtime/js/40_testing.js:272:24)
at AsyncGenerator.next (<anonymous>)
at Object.runTests (deno:runtime/js/40_testing.js:347:22)
at async file:///Users/gigi.sayfan/git/deno_test/$deno$test.ts:3:1
failures:
Palindrome 3 — fail
test result: FAILED. 2 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out (4ms)
I’ll leave fixing the test to you as the dreaded exercise to the reader!
Bundling
Bundling lets you package all the modules and dependencies of your program into a single bundle that can be executed without delay. Deno provides the bundle command. Let’s see it in practice. The foobar.ts module imports the foo() function from foo.ts and the bar() function from bar.ts.
Here is foo.ts:
Here is bar.ts:
https://gist.github.com/the-gigi/2d48187fcc1e11ad6c27299487b1a9e0
Here is foobar.ts:
Let’s bundle them all up into a single file:
$ deno bundle foobar.ts
Bundle file:///Users/gigi.sayfan/git/deno_test/foobar.ts
Check file:///Users/gigi.sayfan/git/deno_test/foobar.ts
Here is the result:
As you can see the import statements are no longer needed because foo() and bar() are all embedded in the single bundle file and can be invoked directly.
Debugging
Deno allows you to debug your programs via the V8 inspector protocol. You can use Chrome DevTools and run your program with the — inspect or — inspect-brk flags. I personally prefer JetBrains IDEs and the Deno plugin that lets me debug Deno code using native JetBrains debugging with full-fledged breakpoints, watches, and stack traces.
If your weapon of choice is VS Code then a Deno plugin is in progress. In the meantime you can attach the debugger manually using the following launch.json configuration file:
Script installation
Running deno programs using deno run is fine in many cases, but if you need to pass a lot of permission flags and you want to be able to run the program from any location there is a better choice. Deno provides the deno install command that creates a little shell script to invoke your Deno program and places it in a location for your designation or in $HOME/.deno/bin
Let’s install our foobar program:
$ deno install foobar.ts
✅ Successfully installed foobar
/Users/gigi.sayfan/.deno/bin/foobar
ℹ️ Add /Users/gigi.sayfan/.deno/bin to PATH
export PATH=”/Users/gigi.sayfan/.deno/bin:$PATH”
I added $HOME/.deno/bin to my PATH and now I can run foobar from anywhere just by typing foobar:
$ cd /tmp
$ foobar
foo
bar
Deno internals
We reviewed Deno's capabilities and the user experience. Let’s take a look under the hood. Deno is implemented using Rust and TypeScript. Here are the main components of Deno:
- deno
- deno_core
- tsc
- swc
- rusty_v8
The deno crate is the deno executable, which you interact with.
The deno_core crate is responsible for the Javascript execution runtime. Deno core relies on the Tokio crate for implementing its async event loop.
The tsc tool is the standard TypeScript compiler. It used to be Deno’s TypeScript compiler. Now, it is mostly responsible for type checking.
The swc project stands for Speedy Web Compiler and it takes on more and more work to compile your Javascript and Typescript code to Javascript that can be executed on any browser.
Finally, the rustry_v8 crate provides Rust bindings to the V8 C++ API.
Conclusion
Deno is a young project with a lot of momentum. It is built on the experience and hard lessons of Node.js. It has a lot of technical improvements over Node.js. It is implemented using a modern tech stack. The big question is if it’s going to take over as the primary Javascript and Typescript backend runtime. I think it’s too early to tell, but it should be very interesting to watch what happens. If you have some server-side project you planned to implement using Node.js consider giving Deno a try.
As with everything else, choosing between Node.js, Deno, or some other technology should mostly depend on the needs of your project and the expertise of your team.
If you already have something built with Node.js that is working well, rewriting it to Deno without some other benefit might not be the most optimal decision.
For example, one of the backend services of SessionStack is built in Node.js.
The reason is that SessionStack’s library sends a wide variety of data from the browser such as DOM changes, user interactions, JavaScript exceptions, stack traces, network requests, and debug messages. This data is later used by SessionStack’s platform which allows teams to replay user journeys as videos in order to optimize product workflows, reproduce bugs, or see where users are stuck.
The service that is consuming the data from the browser is very heavy on I/O operations. This was a great use case for Node.js and Deno was not available back then.
However, we are excited to monitor how Deno evolves and potentially it can be included in our stack in the future.
There is a free trial if you’d like to give SessionStack a try.
If you missed the previous chapters of the series, you can find them here:
- An overview of the engine, the runtime, and the call stack
- Inside Google’s V8 engine + 5 tips on how to write optimized code
- Memory management + how to handle 4 common memory leaks
- The event loop and the rise of Async programming + 5 ways to better coding with async/await
- Deep dive into WebSockets and HTTP/2 with SSE + how to pick the right path
- A comparison with WebAssembly + why in certain cases it’s better to use it over JavaScript
- The building blocks of Web Workers + 5 cases when you should use them
- Service Workers, their life-cycle, and use cases
- The mechanics of Web Push Notifications
- Tracking changes in the DOM using MutationObserver
- The rendering engine and tips to optimize its performance
- Inside the Networking Layer + How to Optimize Its Performance and Security
- Under the hood of CSS and JS animations + how to optimize their performance
- Parsing, Abstract Syntax Trees (ASTs) + 5 tips on how to minimize parse time
- The internals of classes and inheritance + transpiling in Babel and TypeScript
- Storage engines + how to choose the proper storage API
- The internals of Shadow DOM + how to build self-contained components
- WebRTC and the mechanics of peer to peer connectivity
- Under the hood of custom elements + Best practices on building reusable components
- Exceptions + best practices for synchronous and asynchronous code
- 5 types of XSS attacks + tips on preventing them
- CSRF attacks + 7 mitigation strategies
- Iterators + tips on gaining advanced control over generators
- Cryptography + how to deal with man-in-the-middle (MITM) attacks
- Functional style and how it compares to other approaches
- Three types of polymorphism
- Regular expressions (RegExp)