Example: TypeScript
This example showcases a setup of multiple ES modules that can import from each other,
as well as integration with JavaScript tooling during cargo build
.
tip
For best results, view this page in the book.
It embeds the typescript
compiler in order to type check the example
source code itself.
To run this example, run:
cargo run --package example-ts
important
This example requires the deno
CLI to build.
Output of this example. Errors are expected since typescript
as used in this example
actually does not have access to these types.
tip
See also microsoft/typescript-go, which makes this whole thing kinda silly.
The setup
src/lib.rs
lib.rs
does the following things:
- Embed JS dependencies and export them as Rust structs.
- Provide some reusable interface definitions and utility functions.
The ts-blank-space
example reuses
this module because they also require typescript
.
Embedding typescript
use ferrosaur::js;
#[js(module("../dist/typescript.js", url("npm:typescript"), fast(unsafe_debug)))]
pub struct TypeScriptLib;
-
The embedded file is
"../dist/typescript.js"
. This file is emitted by esbuild duringcargo build
. The actual source file is undersrc/deps
. -
url("npm:typescript")
sets the module specifier to"npm:typescript"
.lib.ts
and other modules in the runtime will then be able to do
import ts from "npm:typescript"
. -
fast(unsafe_debug)
embeds the JS file as a fast V8 string while skipping compile-time assertion that it is in ASCII. This is because thetypescript
lib is massive and doing so will take a long time.esbuild already ensures that its build output is ASCII-only, so it is safe in this case.
Embedding @typescript/vfs
#[js(module(
"../dist/typescript-vfs.js",
url("npm:@typescript/vfs"),
fast(unsafe_debug)
))]
pub struct TypeScriptVfs;
This example additionally uses @typescript/vfs
to setup the files necessary
for typescript
to type check. These files are also embedded into the program,
albeit via a dedicated build step.
@typescript/vfs
is the same system that enables the TypeScript playground
to run in the browser.
Declaring interfaces for lib.ts
#[js(interface)]
pub trait Compiler {
#[js(func)]
fn create_program(&self, root: serde<HashMap<String, String>>) -> Program {}
}
#[js(value)]
pub struct Program;
#[js(interface)]
impl Program {
#[js(func)]
pub fn print_diagnostics(&self, colored: bool) -> serde<String> {}
}
#[js(interface)]
is being used on a trait Compiler
here. This
turns Compiler
into sort of a marker trait,
and enables a form of duck typing.
It is essentially saying "any Rust type that implements Compiler
will provide the
create_program
function." For example, the Example
struct, which embeds lib.ts
:
#[js(module("../dist/lib.js", fast))]
pub struct Example;
impl Compiler for Example {}
Of course, ferrosaur cannot actually verify such an implementation, so it is up to the programmer to guarantee that implementors of such traits actually provide the specified interfaces.
Helpers
inject_lib_dts
This function defines a few properties on globalThis
to be used in the example.
Notably, it injects TYPESCRIPT_LIB
. On the Rust side, this is the embedded
declaration files. On the JavaScript side, this is used to create the
virtual file system.
pub fn inject_lib_dts(rt: &mut JsRuntime) -> Result<()> {
#[js(global_this)]
struct Global;
#[js(interface)]
impl Global {
#[js(set_index)]
fn define_object(&self, name: serde<&str>, value: v8::Global<v8::Object>) {}
#[js(set_index)]
fn define_string(&self, name: serde<&str>, value: serde<&str>) {}
}
let global = Global::new(rt);
let dts = {
let scope = &mut rt.handle_scope();
dts::dts(scope)?
};
global.define_object("TYPESCRIPT_LIB", dts, rt)?;
global.define_string("CARGO_MANIFEST_DIR", env!("CARGO_MANIFEST_DIR"), rt)?;
Ok(())
}
mod dts
lib.dts.rs
is the generated file that embeds declaration files.
See build.rs.
mod dts {
include!(concat!(env!("OUT_DIR"), "/lib.dts.rs"));
}
Additional setup code
use std::collections::HashMap;
use anyhow::Result;
use example_runtime::deno_core::{self, JsRuntime};
src/lib.ts
lib.ts
integrates typescript
and implements functions used in this compiler. It is
embedded by lib.rs
.
File src/lib.ts
import ts from "npm:typescript";
import {
createDefaultMapFromNodeModules,
createSystem,
createVirtualCompilerHost,
} from "npm:@typescript/vfs";
export function createProgram(root: Record<string, string>) {
const options: ts.CompilerOptions = {
strict: true,
noEmit: true,
lib: [ts.getDefaultLibFileName({ target: ts.ScriptTarget.ESNext })],
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
moduleDetection: ts.ModuleDetectionKind.Force,
};
let files: Map<string, string>;
if (globalThis.TYPESCRIPT_LIB) {
files = new Map();
for (const [lib, dts] of Object.entries(globalThis.TYPESCRIPT_LIB)) {
files.set(`/${lib}`, dts);
}
} else {
files = createDefaultMapFromNodeModules(options, ts);
}
for (const [name, src] of Object.entries(root)) {
files.set(name, src);
}
const { compilerHost } = createVirtualCompilerHost(createSystem(files), options, ts);
const program = ts.createProgram(Object.keys(root), options, compilerHost);
return {
printDiagnostics: (colored = true) => {
const diagnostics = [
...program.getGlobalDiagnostics(),
...program.getSyntacticDiagnostics(),
...program.getDeclarationDiagnostics(),
...program.getSemanticDiagnostics(),
];
if (colored) {
return ts.formatDiagnosticsWithColorAndContext(diagnostics, compilerHost);
} else {
return ts.formatDiagnostics(diagnostics, compilerHost);
}
},
};
}
declare global {
namespace globalThis {
var TYPESCRIPT_LIB: Record<string, string> | undefined;
var CARGO_MANIFEST_DIR: string | undefined;
}
}
src/main.rs
#[tokio::main]
async fn main() -> Result<()> {
let rt = &mut deno()?;
Initialize the modules:
use example_ts::{inject_lib_dts, Example, TypeScriptLib, TypeScriptVfs};
TypeScriptLib::side_module_init(rt).await?;
TypeScriptVfs::side_module_init(rt).await?;
let example = Example::main_module_init(rt).await?;
Prepare the VFS. This is very similar to the example in @typescript/vfs
:
inject_lib_dts(rt)?;
let file = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("src/lib.ts")
.pipe(std::fs::read_to_string)?;
let root = {
let mut map = HashMap::new();
map.insert("src/lib.ts".into(), file);
map
};
Now, type check:
let program = example.create_program(root, rt)?;
let errors = program.print_diagnostics(true, rt)?;
println!("{errors}");
let errors = program.print_diagnostics(false, rt)?;
insta::assert_snapshot!(errors, @r"
src/lib.ts(1,16): error TS2307: Cannot find module 'npm:typescript' or its corresponding type declarations.
src/lib.ts(6,8): error TS2307: Cannot find module 'npm:@typescript/vfs' or its corresponding type declarations.
");
Ok(())
}
Additional setup code
use std::{collections::HashMap, path::Path};
use anyhow::Result;
use example_runtime::deno;
use example_ts::Compiler;
use tap::Pipe;
build.ts
build.ts
compiles TypeScript files to JavaScript using esbuild at the time of
cargo build
.
Compiling is necessary because:
-
deno_core
itself does not run TypeScript files. -
The
typescript
lib is distributed in CommonJS. esbuild transforms it into ESM so that it can be imported.
File build.ts
import { build, relpath } from "../_runtime/src/lib.ts";
await build({
entryPoints: [relpath("src/lib.ts", import.meta)],
outdir: "dist",
// since we are bundling for ESM, this is required for esbuild to consider
// `typescript`, which is in CJS, to be importable
mainFields: ["module", "main"],
// these modules are made available at runtime
external: ["npm:typescript", "npm:@typescript/vfs"],
bundle: true,
});
await build({
entryPoints: [
relpath("src/deps/typescript.ts", import.meta),
relpath("src/deps/typescript-vfs.ts", import.meta),
],
outdir: "dist",
external: ["fs", "path", "os", "inspector"],
mainFields: ["module", "main"],
bundle: true,
minify: false,
treeShaking: true,
});
build.rs
build.rs
does a few things:
-
Run
build.ts
viadeno
to compile the.ts
files used in this example. -
Generate Rust code that will embed TypeScript's
lib
declarations into the program.These files contain definitions for ECMAScript language APIs, such as essential types like
Promise
, without whichtypescript
will not know how to type check.The generated Rust functions are emitted under
OUT_DIR
and then included inlib.rs
usinginclude!
.
File build.rs
use std::{
path::Path,
process::{Command, Stdio},
};
use anyhow::{bail, Context, Result};
use proc_macro2::TokenStream;
use quote::quote;
fn main() -> Result<()> {
println!("cargo::rerun-if-changed=build.ts");
let built = Command::new("deno")
.args(["run", "--allow-all", "build.ts"])
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.context("failed to bundle JavaScript resources, is Deno installed?")?
.wait()?
.success();
if !built {
bail!("failed to bundle JavaScript resources")
}
let lib_dts = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../node_modules/typescript/lib")
.read_dir()?
.filter_map(|entry| -> Option<TokenStream> {
let path = entry.ok()?.path();
let name = path.file_name()?.to_str()?;
if !name.ends_with(".d.ts") {
return None;
}
let text = std::fs::read_to_string(&path).ok()?;
let path = path.to_string_lossy();
println!("cargo::rerun-if-changed={path}");
if text.is_ascii() {
Some(quote! {
#[allow(long_running_const_eval)]
(ascii_str!(#name), FastString::from(ascii_str_include!(#path)))
})
} else {
Some(quote! {
(ascii_str!(#name), FastString::from_static(include_str!(#path)))
})
}
});
let lib_dts = quote! {
use ::example_runtime::deno_core::{
anyhow::Result, v8,
FastString, ascii_str, ascii_str_include,
};
pub fn dts(scope: &mut v8::HandleScope) -> Result<v8::Global<v8::Object>> {
let obj = v8::Object::new(scope);
let files = [ #(#lib_dts),* ];
for (lib, dts) in files {
let lib = lib.v8_string(scope)?;
let dts = dts.v8_string(scope)?;
obj.set(scope, lib.into(), dts.into()).unwrap();
}
Ok(v8::Global::new(scope, obj))
}
};
std::fs::write(
Path::new(&std::env::var("OUT_DIR")?).join("lib.dts.rs"),
lib_dts.to_string(),
)?;
Ok(())
}
src/deps/*
These files re-export the respective libs for bundling.
File src/deps/typescript.ts
export { default } from "typescript";
File src/deps/typescript-vfs.ts
export * from "@typescript/vfs";