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.

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.

screenshot of the example

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;

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 via deno 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 which typescript will not know how to type check.

    The generated Rust functions are emitted under OUT_DIR and then included in lib.rs using include!.

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";