Skip to content

Files

Latest commit

 

History

History
1096 lines (841 loc) · 23.3 KB

presentation.md

File metadata and controls

1096 lines (841 loc) · 23.3 KB
marp theme class style paginate
true
uncover
invert
.container { display: flex; } .col { flex: 1; }
true

Oxidising Your TypeScript Projects 🦀

Using WASM and Rust for fun and profit

Kat Marchán


whoami

  • former NPM CLI architect (JavaScript)
  • former member of NuGet team (C#)
  • TypeScript Developer Tools team at Microsoft (TS)
  • Open Source maintainer (Rust)

What's all this about?

  • Exploration of real-world problem
  • What's wasm?
  • Talk about what wasm can and can't do: pros and cons
  • A look at actual code
  • Demo videos

The Problem

No Type Acquisition in vscode.dev


Without Type Acquisition


Why this is hard

  • Type Acquisition on desktop uses node_modules and NPM
  • No NPM on web!
  • Getting the right types packages requires PM logic
  • Running NPM on hosted VMs = expensive!

Why this is hard

  • No existing, maintained package manager in JavaScript for web
  • Missing supporting libraries to build one (tar is node-only!)
  • Performance is important
  • Want to (eventually) work with private registries

Solution: WASM to the Rescue!

  • PM right on the edge: on the browser itself!
  • Use "systems" language with existing supporting libraries/tooling (Rust)
  • Use existing package manager and bundle it up in WASM (Orogene)

bg

🦀 Rewrite it in Rust 🦀


What's WASM?

  • WebAssembly
  • Native VM for the web
  • All browsers, major JS runtimes, even dedicated runtimes
  • Compile-once-run-everywhere
  • Secure enough for the web

WASM Pros

  • Use existing native libraries
  • High perf profile
  • No need for hosted VM

WASM Cons

  • Not JavaScript/TypeScript
  • Large-ish binary blob (~2mb)
  • BYOGC on the JS API level (Bring your own garbage collection)

What we did

  • Bundle Orogene's resolver into a wasm module (node-maintainer) using wasm-bindgen
  • Use tsify to extract TypeScript types
  • Publish compiled wasm packages to NPM with wasm-pack
  • Wrap everything up in a TypeScript tool
  • Shove it all in VS Code!

wasm-bindgen

Where the magic starts

wasm-bindgen hello world

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern "C" {
    fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet(name: &str) {
    alert(&format!("Hello, {}!", name));
}

wasm-bindgen import from JS

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern "C" {
    fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet(name: &str) {
    alert(&format!("Hello, {}!", name));
}

wasm-bindgen export to JS

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern "C" {
    fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet(name: &str) {
    alert(&format!("Hello, {}!", name));
}

wasm-bindgen import wasm module

import { greet } from "./pkg";

// Calls `window.alert("World")`
greet("World");

tsify for .d.ts generation

use serde::{Deserialize, Serialize};
use tsify::Tsify;
use wasm_bindgen::prelude::*;

#[derive(Tsify, Serialize, Deserialize)]
#[tsify(into_wasm_abi, from_wasm_abi)]
pub struct Point {
    x: i32,
    y: i32,
}

#[wasm_bindgen]
pub fn into_js() -> Point {
    Point { x: 0, y: 0 }
}

#[wasm_bindgen]
pub fn from_js(point: Point) {}

tsify for .d.ts generation

use serde::{Deserialize, Serialize};
use tsify::Tsify;
use wasm_bindgen::prelude::*;

#[derive(Tsify, Serialize, Deserialize)]
#[tsify(into_wasm_abi, from_wasm_abi)]
pub struct Point {
    x: i32,
    y: i32,
}

#[wasm_bindgen]
pub fn into_js() -> Point {
    Point { x: 0, y: 0 }
}

#[wasm_bindgen]
pub fn from_js(point: Point) {}

Generated .d.ts file

/**
 * @returns {Point}
 */
export function into_js(): Point;
/**
 * @param {Point} point
 */
export function from_js(point: Point): void;
export interface Point {
  x: number;
  y: number;
}

wasm-pack

The final piece of the puzzle


bg fit


wasm-pack

Single-command wasm + NPM + publishing.


We can build it

We have the technology

First Step

  • Conditionally import it.
  • Put a wasm.rs module in your Rust project.

bg fit


Second Step

Import/export it only in wasm target

// lib.rs
#[cfg(not(target_arch = "wasm32"))]
pub use maintainer::*;
#[cfg(target_arch = "wasm32")]
pub use wasm::*;

#[cfg(target_arch = "wasm32")]
mod wasm;
mod error;
mod graph;
// ...snip...

Second Step

Import/export it only in wasm target

// lib.rs
#[cfg(not(target_arch = "wasm32"))]
pub use maintainer::*;
#[cfg(target_arch = "wasm32")]
pub use wasm::*;

#[cfg(target_arch = "wasm32")]
mod wasm;
mod error;
mod graph;
// ...snip...

Second Step

Import/export it only in wasm target

// lib.rs
#[cfg(not(target_arch = "wasm32"))]
pub use maintainer::*;
#[cfg(target_arch = "wasm32")]
pub use wasm::*;

#[cfg(target_arch = "wasm32")]
mod wasm;
mod error;
mod graph;
// ...snip...

bg fit


#[derive(Tsify)]
#[wasm_bindgen]
pub struct NodeMaintainer {
    #[wasm_bindgen(skip)]
    pub inner: crate::maintainer::NodeMaintainer,
}

impl NodeMaintainer {
    fn new(inner: crate::maintainer::NodeMaintainer) -> Self {
        Self { inner }
    }
}
  • Baseline: Create a foreign type for TypeScript and a private constructor that holds the "internal" version of the type. Skip the internal field.

#[wasm_bindgen]
impl NodeMaintainer {
    #[wasm_bindgen(js_name = "resolveManifest")]
    pub async fn resolve_manifest(
        manifest: PackageJson,
        opts: Option<NodeMaintainerOptions>,
    ) -> Result<NodeMaintainer> {
        console_error_panic_hook::set_once();
        let manifest = serde_wasm_bindgen::from_value(manifest.into())?;
        let opts_builder = Self::opts_from_js_value(opts)?;
        opts_builder
            .resolve_manifest(manifest)
            .await
            .map(NodeMaintainer::new)
    }
}
  • Export a static method that will return a NodeMaintainer object to JS.

#[wasm_bindgen]
impl NodeMaintainer {
    #[wasm_bindgen(js_name = "resolveManifest")]
    pub async fn resolve_manifest(
        manifest: PackageJson,
        opts: Option<NodeMaintainerOptions>,
    ) -> Result<NodeMaintainer> {
        console_error_panic_hook::set_once();
        let manifest = serde_wasm_bindgen::from_value(manifest.into())?;
        let opts_builder = Self::opts_from_js_value(opts)?;
        opts_builder
            .resolve_manifest(manifest)
            .await
            .map(NodeMaintainer::new)
    }
}
  • Take a regular JS object, which is actually just a representation of an object that lives in JS memory.

#[wasm_bindgen]
impl NodeMaintainer {
    #[wasm_bindgen(js_name = "resolveManifest")]
    pub async fn resolve_manifest(
        manifest: PackageJson,
        opts: Option<NodeMaintainerOptions>,
    ) -> Result<NodeMaintainer> {
        console_error_panic_hook::set_once();
        let manifest = serde_wasm_bindgen::from_value(manifest.into())?;
        let opts_builder = Self::opts_from_js_value(opts)?;
        opts_builder
            .resolve_manifest(manifest)
            .await
            .map(NodeMaintainer::new)
    }
}
  • To make things easier, we import the data from the JS side into a Rust memory-hosted object, using serde_wasm_bindgen.

Using it from JavaScript

import { NodeMaintainer } from "node-maintainer";
import pkgJson from "./package.json"
    assert { type: "json" };

// Throws error if Rust returns `Result::Err`.
const resolvedTree: NodeMaintainer =
    await NodeMaintainer(pkgJson);

Takeaways

  • JavaScript objects exist in JavaScript memory
  • Rust objects stay isolated in flat wasm memory block
  • Interaction is an illusion created by wasm_bindgen etc glue code
  • Everything is literally pointers (the "just a number" kind)
  • You can (de)serialize to move data between the two worlds
  • Using it from JavaScript is transparent and easy.

#[wasm_bindgen(typescript_custom_section)]
const TS_APPEND_CONTENT: &'static str = r#"
export interface NodeMaintainerOptions {
    registry?: string;
    scopedRegistries?: Record<string, string>;
    concurrency?: number;
    kdlLock?: string;
    npmLock?: string;
    defaultTag?: string;
}
"#;

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(typescript_type = "NodeMaintainerOptions")]
    pub type NodeMaintainerOptions;
}
  • You can define custom TS types.

#[wasm_bindgen(typescript_custom_section)]
const TS_APPEND_CONTENT: &'static str = r#"
export interface NodeMaintainerOptions {
    registry?: string;
    scopedRegistries?: Record<string, string>;
    concurrency?: number;
    kdlLock?: string;
    npmLock?: string;
    defaultTag?: string;
}
"#;

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(typescript_type = "NodeMaintainerOptions")]
    pub type NodeMaintainerOptions;
}
  • ...and then import them into Rust.

#[wasm_bindgen]
impl NodeMaintainer {
    fn opts_from_js_value(
        opts: Option<NodeMaintainerOptions>,
    ) -> Result<crate::maintainer::NodeMaintainerOptions> {
        // ...snip...
        let mut opts_builder = crate::maintainer::NodeMaintainer::builder();
        let opts: Option<ParsedNodeMaintainerOptions> =
            if let Some(opts) = opts {
                serde_wasm_bindgen::from_value(opts.into())?
            } else {
                None
            };
        } else {
            None
        };
        // ...snip...
        }
        Ok(opts_builder)
    }
  • We're gonna create a Rust-side Builder that we'll pass our options to. This is how options are usually passed in Rust.

#[wasm_bindgen]
impl NodeMaintainer {
    fn opts_from_js_value(
        opts: Option<NodeMaintainerOptions>,
    ) -> Result<crate::maintainer::NodeMaintainerOptions> {
        // ...snip...
        let mut opts_builder = crate::maintainer::NodeMaintainer::builder();
        let opts: Option<ParsedNodeMaintainerOptions> =
            if let Some(opts) = opts {
                serde_wasm_bindgen::from_value(opts.into())?
            } else {
                None
            };
        // ...snip...
        }
        Ok(opts_builder)
    }
  • For the incoming options, we transfer them over to Rust memory by deserializing them.

#[wasm_bindgen]
impl NodeMaintainer {
    fn opts_from_js_value(
        opts: Option<NodeMaintainerOptions>,
    ) -> Result<crate::maintainer::NodeMaintainerOptions> {
        // ...snip...
        if let Some(opts) = opts {
            if let Some(registry) = opts.registry {
                opts_builder = opts_builder.registry(
                    registry
                        .parse()
                        .map_err(|e| NodeMaintainerError::UrlParseError(registry, e))?,
                );
            }
        // ...snip...
        }
        Ok(opts_builder)
    }
  • Now we can pass/parse/validate values as we pass them to the Builder.

Takeaways

  • Sometimes, you want to define custom TS types for your interface.
  • Not strictly necessary, but improves TS experience.
  • Then import them as if you were doing regular FFI in Rust
  • It may make sense to do more manual parsing and validation of values/options, because JS and Rust patterns are different.

But Wait!

  • JS<->Rust interactions can be even more complex!
  • Let's look at the contents of the resolved tree: Packages

#[wasm_bindgen]
pub struct Package {
    // All actually internal.
    #[wasm_bindgen(skip)]
    pub from: JsString,
    #[wasm_bindgen(skip)]
    pub name: JsString,
    #[wasm_bindgen(skip)]
    pub resolved: JsString,
    package: crate::package::Package,
    serializer: serde_wasm_bindgen::Serializer,
}
  • Again, we wrap an opaque Rust type in something that JS can see.

#[wasm_bindgen]
pub struct Package {
    // All actually internal.
    #[wasm_bindgen(skip)]
    pub from: JsString,
    #[wasm_bindgen(skip)]
    pub name: JsString,
    #[wasm_bindgen(skip)]
    pub resolved: JsString,
    package: crate::package::Package,
    serializer: serde_wasm_bindgen::Serializer,
}
  • These are actually JS values, but we're only going to expose them through getters, so they're read-only, hence the skip.

#[wasm_bindgen]
impl Package {
    pub async fn entries(&self) -> Result<EntryReadableStream> {
  • We define a method for extracting package contents.
  • This will do a lot of juggling between JavaScript and Rust types to stream data.
  • Everything looks like native JavaScript types on the other end.

let entries = self.package.entries()
    .await?.then(|entry| async move {
        entry.map_err(|e| e.into()).and_then(
            |entry: crate::entries::Entry| ->
                std::result::Result<JsValue, JsValue> {
  • Some incantation for creating and iterating over the async Rust Stream.
  • Internals of this method are actually executing async Rust logic, not JS logic!

let entries = self.package.entries()
    .await?.then(|entry| async move {
        entry.map_err(|e| e.into()).and_then(
            |entry: crate::entries::Entry| ->
                std::result::Result<JsValue, JsValue> {
  • We're gonna turn (Rust) Entrys into (JS) JsValues in our handler.

// Top of the file
use wasm_streams::ReadableStream;

// ...snip...
let obj = js_sys::Object::new();
//...snip...
js_sys::Reflect::set(
    // ...snip...
)?;
js_sys::Reflect::set(
    // ...snip...
)?;
Ok(obj.into())
  • We're going to create the JS-side Entry object and use reflection to assign values directly to it.

js_sys::Reflect::set(
    &obj,
    &"path".into(),
    &entry.path()?.to_string_lossy().into_owned().into(),
)?;
js_sys::Reflect::set(
    &obj,
    &"contents".into(),
    &ReadableStream::from_async_read(entry, 1024)
        .into_raw()
        .into(),
)?;
  • We convert our values into JS values (such as JS' ReadableStream) and assign those through reflection.

js_sys::Reflect::set(
    &obj,
    &"contents".into(),
    &ReadableStream::from_async_read(entry, 1024)
        .into_raw()
        .into(),
)?;
  • JS will end up iterating over a package stream of Entrys, and those entries will have streamable data themselves (content).
  • Data will be converted between Rust and JS memory in the background as needed, on the fly.

Wrapping up

let entries = self.package.entries()
    //... snip...
    let jsval: JsValue = ReadableStream::from_stream(entries)
        .into_raw()
        .into();
    Ok(jsval.into())
}
  • Wrap the Rust-native Stream with a JS-side ReadableStream as a JsValue.

Wrapping up

let entries = self.package.entries()
    //... snip...
    let jsval: JsValue = ReadableStream::from_stream(entries)
        .into_raw()
        .into();
    Ok(jsval.into())
}
  • And turn the generic JsValue into a concrete TypeScript ReadableStream<Entry>.

Takeaways

  • You can wrap very complex and rich Rust and TypeScript data structures
  • There's Reflection capabilities that let you create and modify TypeScript objects directly in Rust code (even call JS functions!)
  • All of this results in well-typed interfaces

import {
  resolveManifest,
  initSync,
  Nassun,
  NodeMaintainer,
  Package,
  Entry,
} from "node-maintainer";
import nmWasm from "node-maintainer/node_maintainer_bg.wasm";

initSync(dataURItoUint8Array(nmWasm as unknown as string));
  • Now we can consume it in TypeScript!

import {
  resolveManifest,
  initSync,
  Nassun,
  NodeMaintainer,
  Package,
  Entry,
} from "node-maintainer";
import nmWasm from "node-maintainer/node_maintainer_bg.wasm";

initSync(dataURItoUint8Array(nmWasm as unknown as string));
  • There's a bit of juggling that needs to happen on import, which varies depending on the bundling strategy you're using.

export class PackageManager {
  // ...snip...
  async resolveProject(root: string, opts: InstallProjectOpts = {}) {
    const maintainer = await this.resolveMaintainer(root, opts);
    return new ResolvedProject(this.fs, maintainer, root);
  }
  private async resolveMaintainer(
    root: string,
    opts: InstallProjectOpts
  ): Promise<NodeMaintainer> {
    return resolveManifest(JSON.parse(opts.pkgJson.trim()), {
      npmLock: opts.npmPkgLock,
    });
  }
  // ...snip...
}
  • Instead of exposing the NodeMaintainer directly, we use it as a library to create a wrapper that works against a virtual filesystem.

Takeaways

  • wasm module consumers don't usually have to care that something is wasm.
  • while you usually just need to import a wasm package, you sometimes need to do some backflips depending on your specific JS bundler.
  • wrapping the imported wasm stuff in another API layer is a good idea.

export interface FileSystem {
  readDirectory(path: string): string[];
  deleteFile?(path: string): void;
  createDirectory(path: string): void;
  writeFile(path: string, data: string, writeByteOrderMark?: boolean): void;
  directoryExists(path: string): boolean;
}
  • We define a (limited) virtual filesystem interface, which we will implement ourselves.

export declare class ResolvedProject {
  restore(): Promise<void>;
  restorePackageAt(path: string): Promise<number | undefined>;
  pruneExtraneous(): number;
}
  • Using the typestate pattern, we keep PackageManager and ResolvedProject separate.

Takeaways

  • The rest is regular TypeScript code, with any wrapped Rust types hidden deep in implementation.
  • Aside: typestate pattern is great!

Installing the ResolvedProject

  • We have a virtual filesystem.
  • We have a ResolvedProject instance, created by our PackageManager.
  • Time to extract all that data into our "filesystem"!

export class ResolvedProject {
  // ...snip...
  async restore() {
    this.pruneExtraneous();
    await this.extractMissing();
    this.writeLockfile();
  }
  // ...snip
}
  • "restore" is what this package manager calls its "install" operation. It's just three steps:
    • pruning existing packages that don't need to be there anymore
    • extracting missing packages
    • and writing a lockfile. That's it!

export class ResolvedProject {
  // ...snip...
  async restore() {
    this.pruneExtraneous();
    await this.extractMissing();
    this.writeLockfile();
  }
  // ...snip
}
  • We're just going to focus on this step, which is the most interesting one.

export class ResolvedProject {
  // ...snip...
  private async extractMissing() {
    const meta: MetaFile = { packages: {} };
    await this.maintainer.forEachPackage(async (pkg: Package, path: string) => {
      const fullPath = join(this.prefix, path);
      meta.packages![path] = {
        name: pkg.name,
        resolved: pkg.resolved,
      };
      try {
        if (!this.fs.directoryExists(fullPath)) {
          await this.extractPackageTo(pkg, fullPath);
        }
      } finally {
        pkg.free();
      }
    });
    rimraf(this.fs, this.metaPath);
    this.fs.writeFile(this.metaPath, JSON.stringify(meta, null, 2));
  }
  // ...snip
}
  • This is the whole definition! Let's break it down.

private async extractMissing() {
  const meta: MetaFile = { packages: {} };
  await this.maintainer.forEachPackage(async (pkg: Package, path: string) => {
    // ...snip...
  });
  rimraf(this.fs, this.metaPath);
  this.fs.writeFile(this.metaPath, JSON.stringify(meta, null, 2));
}
  • We're going to iterate over all the packages in the NodeMaintainer instance.
  • Iteration is going to happen concurrently, with multiple packages extracted at once.
  • forEachPackage is a method defined on the Rust side of things.

await this.maintainer.forEachPackage(async (pkg: Package, path: string) => {
  const fullPath = join(this.prefix, path);
  // ...snip...
  try {
    if (!this.fs.directoryExists(fullPath)) {
      await this.extractPackageTo(pkg, fullPath);
    }
  } finally {
    pkg.free();
  }
});
  • For each package, get the full path, and if it doesn't exist, extract it.

await this.maintainer.forEachPackage(async (pkg: Package, path: string) => {
  const fullPath = join(this.prefix, path);
  // ...snip...
  try {
    if (!this.fs.directoryExists(fullPath)) {
      await this.extractPackageTo(pkg, fullPath);
    }
  } finally {
    pkg.free();
  }
});
  • Remember the whole "garbage collection" thing? Well, we have to deallocate our Rust objects by hand. This is important!

#[wasm_bindgen(js_name = "forEachPackage")]
pub async fn for_each_package(&self, f: &js_sys::Function) -> std::result::Result<(), JsValue> {
    futures::stream::iter(self.inner.graph.inner.node_indices())
        .map(Ok)
        .try_for_each_concurrent(10, move |idx| async move {
            // ...snip...
        }).await
}
  • Coming back to the Rust side, we see the definition of forEachPackage, using native Rust Stream functions.

#[wasm_bindgen(js_name = "forEachPackage")]
pub async fn for_each_package(&self, f: &js_sys::Function) -> std::result::Result<(), JsValue> {
    futures::stream::iter(self.inner.graph.inner.node_indices())
        .map(Ok)
        .try_for_each_concurrent(10, move |idx| async move {
            // ...snip...
        }).await
}
  • We're even using try_for_each_concurrent to iterate over the packages concurrently, in batches of 10 at a time.
  • Side note: Packages themselves are fetched through reflection against the JS fetch Web API, all under the hood.

.try_for_each_concurrent(10, move |idx| async move {
    // ..snip...
    let promise: Option<Promise> = f
        .call2(
            &JsValue::NULL,
            &Package::from_core_package(pkg.clone()).into(),
            &(&path).into(),
        )?
        .dyn_into()
        .ok();
    if let Some(promise) = promise {
        JsFuture::from(promise).await?;
    }
    Ok::<_, JsValue>(())
    // ...snip...
}
  • We can even call that async JS callback and send back our own promise, from a Rust Future!

Takeaways

  • You have to remember to .free() your Rust objects in finally blocks!
  • Rust can call JS async functions, deal with them as Futures, and send back JS Promises, all transparently.
  • You can use all the nice Rust concurrency features in the background.

That's it!

We have a package manager in one talk's worth of time! ;)


The Package Manager, but with WASM


Type Acquisition Enabled


Thanks!