marp | theme | class | style | paginate |
---|---|---|---|---|
true |
uncover |
invert |
.container {
display: flex;
}
.col {
flex: 1;
}
|
true |
Kat Marchán
- former NPM CLI architect (JavaScript)
- former member of NuGet team (C#)
- TypeScript Developer Tools team at Microsoft (TS)
- Open Source maintainer (Rust)
- 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
- 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!
- 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
- 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)
- WebAssembly
- Native VM for the web
- All browsers, major JS runtimes, even dedicated runtimes
- Compile-once-run-everywhere
- Secure enough for the web
- Use existing native libraries
- High perf profile
- No need for hosted VM
- Not JavaScript/TypeScript
- Large-ish binary blob (~2mb)
- BYOGC on the JS API level (Bring your own garbage collection)
- Bundle Orogene's resolver into a wasm module (
node-maintainer
) usingwasm-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!
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
extern "C" {
fn alert(s: &str);
}
#[wasm_bindgen]
pub fn greet(name: &str) {
alert(&format!("Hello, {}!", name));
}
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
extern "C" {
fn alert(s: &str);
}
#[wasm_bindgen]
pub fn greet(name: &str) {
alert(&format!("Hello, {}!", name));
}
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
extern "C" {
fn alert(s: &str);
}
#[wasm_bindgen]
pub fn greet(name: &str) {
alert(&format!("Hello, {}!", name));
}
import { greet } from "./pkg";
// Calls `window.alert("World")`
greet("World");
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) {}
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) {}
/**
* @returns {Point}
*/
export function into_js(): Point;
/**
* @param {Point} point
*/
export function from_js(point: Point): void;
export interface Point {
x: number;
y: number;
}
- Conditionally import it.
- Put a
wasm.rs
module in your Rust project.
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...
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...
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...
#[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
.
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);
- 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.
- 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.
- JS<->Rust interactions can be even more complex!
- Let's look at the contents of the resolved tree:
Package
s
#[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)
Entry
s into (JS)JsValue
s 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
Entry
s, 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.
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-sideReadableStream
as aJsValue
.
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>
.
- 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.
- 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
andResolvedProject
separate.
- The rest is regular TypeScript code, with any wrapped Rust types hidden deep in implementation.
- Aside: typestate pattern is great!
- We have a virtual filesystem.
- We have a
ResolvedProject
instance, created by ourPackageManager
. - 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!
- You have to remember to
.free()
your Rust objects infinally
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.
- Final wrapper was turned into an NPM package:
- WASM code pulled in as a regular dependency, invisible to user