Packages with native code
Most packages are pure Velt. When a package needs code that only exists natively (a database
driver, an image codec, a C library), it can include a Rust crate. Users of the package never
need Rust: you publish a prebuilt library for each platform, and velt downloads the right one.
This chapter writes one; packages/sqlite in the Velt repository is a complete example.
Layout
greet/
package.vlt
src/lib.vlt # the package's Velt API
native/Cargo.toml # the Rust crate
native/src/lib.rs// package.vlt
import type { Package } from "velt/package";
export const pkg: Package = {
name: "greet",
version: "0.1.0",
native: { targets: ["x86_64-unknown-linux-gnu", "aarch64-apple-darwin", "x86_64-pc-windows-msvc"] },
};# native/Cargo.toml
[package]
name = "greet_native"
version = "0.1.0"
edition = "2021"
[lib]
name = "velt_native_greet" # always velt_native_<package>
crate-type = ["cdylib", "staticlib"]
[dependencies]
velt_native = "0.1"velt_native is not on crates.io yet: until it is, depend on it by path or git
(velt_native = { git = "https://github.com/velt-lang/velt" }).
The Rust side
use velt_native::{export, Error};
velt_native::package!(greet); // once per crate: the start-up function
#[export]
fn velt_greet__hello(name: &str, excited: bool) -> String {
format!("hello, {name}{}", if excited { "!" } else { "" })
}
#[export]
fn velt_greet__parse(text: &str) -> Result<i64, Error> {
text.trim().parse().map_err(|e| Error::other(format!("{e}")))
}
#[export(blocking)] // an async function: runs on the runtime's blocking pool
fn velt_greet__slow(n: u64) -> Result<u64, Error> {
std::thread::sleep(std::time::Duration::from_millis(10));
Ok(n * 2)
}- Every exported function's name starts with
velt_, the package name and two underscores (velt_greet__…; a-in the package name becomes_). No C library function starts withvelt_, so an export can never stand in for one. - Parameters are lent for the call only, so references take no lifetime (
&str, not&'static str). - Parameters:
bool, integers,f32/f64,&str/Cow<str>/String(a Veltstring),&[u8]/Vec<u8>(au8[]).blockingfunctions take owned values (String,Vec<u8>). A Velt string is UTF-16 text and may hold a lone surrogate, which Rust text can't: such a string arrives with one U+FFFD in place of each (a copy; well-formed text is borrowed, and aCow<str>parameter tells which). - Results: the same scalars,
(),String,Vec<u8>, orResult<T, velt_native::Error>(anIoResult<T>in Velt;Result<(), Error>is anIoStatus). - Arrays and structs cross too, as in std:
&[T]/Vec<T>/velt_native::Array<T>for aT[], and a#[repr(C)]struct with#[derive(velt_native::Value)]for a Veltstructwith the same fields (user_idisuserId;#[velt(name = "…")]names one). A field that differs in name, order or type is a build error. - A panic becomes an error result (
native panic: …) where the result can carry one. - Objects (a connection, a decoder) stay on the Rust side behind a
u64handle.
Async functions and network I/O
An #[export(async)] async fn is a declare async function whose future runs on the program's
scheduler, polled by the task that awaits it, with no thread in between. With
velt_native = { version = "0.1", features = ["tokio"] }, its sockets are Velt's own and work
with libraries written for tokio:
use velt_native::{export, net::TcpStream, time, Error};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[export(async)]
async fn velt_greet__ping(addr: String) -> Result<String, Error> {
let mut s = TcpStream::connect(&addr).await?;
s.write_all(b"ping\n").await?;
let mut buf = vec![0; 64];
let n = time::timeout(std::time::Duration::from_secs(5), s.read(&mut buf))
.await
.map_err(|e| Error::new(velt_native::code::TIMED_OUT, e.to_string()))??;
Ok(String::from_utf8_lossy(&buf[..n]).into_owned())
}- Parameters are owned (
String,Vec<u8>,Str): the future outlives the call. To read borrowed arguments first (encode a request from a&[Str]without keeping it), write a plainfnthat returnsimpl Future<Output = R> + Send + 'static; the future cannot hold the borrowed arguments, which the compiler checks. velt_native::rt::spawn(fut)runs a background task (a connection's protocol loop) on the current worker.- Don't start another async runtime (a tokio runtime) in the library: its I/O would wake Velt's
tasks across threads. Libraries that only need tokio's I/O traits and
synctypes (most drivers, with their connection future spawned throughrt::spawn) run on Velt's directly.
Strings without copies
A String result is copied into Velt's memory and its UTF-16 length counted; a String
parameter is a copy of Velt's. For text that is large or on a hot path, use Velt's own strings:
use std::fmt::Write;
use velt_native::{export, Str, StrBuf};
#[export]
fn velt_greet__table(rows: u32) -> Str {
// Written straight into the Velt string: no copy when it is done.
let mut out = StrBuf::new();
for i in 0..rows {
let _ = write!(out, "{i}\t{}\n", i * i);
}
out.finish()
}
#[export(async)]
async fn velt_greet__shout(text: Str) -> Str {
// `text` is Velt's string, kept by reference (no copy) for as long as the future runs.
Str::new(&text.to_str().to_uppercase())
}StrBufis a Veltstringbeing built in Velt's memory (push_str,push,write!);finish()makes it the string. Short text stays inline, as Velt keeps it.Stris an owned Veltstring: a parameter (as_bytes(), orto_str(), borrowed when well-formed), a result, an array element or a struct field. Cloning one adds a reference.- Velt's database drivers encode with the
velt_textcrate (JavaScript number formatting, JSON escaping, database rows as JSON), whose writers take anyvelt_text::Out: aVec<u8>, or with itssdkfeature aStrBuf.
The Velt side
The package declares each function with exactly the signature the Rust side exports, and wraps them in a Velt API:
import { IoResult } from "velt/native";
declare function velt_greet__hello(name: string, excited: bool): string;
declare function velt_greet__parse(text: string): IoResult<i64>;
declare async function velt_greet__slow(n: u64): Promise<IoResult<u64>>;
export function hello(name: string): string {
return velt_greet__hello(name, true);
}velt checks every declare against the library: a name it does not export, or a type that
differs, is a compile error at the declare, not a crash at run time:
src/lib.vlt:4:1: error: `declare` of `velt_greet__parse` does not match the native library of `greet 0.1.0`
= note: the library exports `(string)->IoResult<i64>`
= note: this declares `(string)->IoResult<i32>`Only a package's own library can be declared this way. A declare function anywhere else (in
a program or a package without native code, or naming another package's export) is a compile
error, so ordinary Velt code can't call arbitrary C functions such as free.
velt/native also has what turns a failed result into an error: unwrapIo(r) and checkIo(s)
throw an IoError (velt/io), and errorCodeName(code) is the Node-style name of a failure code
("ECONNREFUSED", "ETIMEDOUT"), for a package that throws an error class of its own:
import { errorCodeName, IoStatus } from "velt/native";
export class GreetError extends Error {
code: string;
constructor(code: string, message: string) {
super(message);
this.code = code;
}
}
function check(s: IoStatus) {
if (s.code != 0) {
throw new GreetError(errorCodeName(s.code), s.message);
}
}A database driver hands its rows and a statement's parameters to std's generated decoder and
binder through velt/db's decodeRows and bindParams (velt/db: native drivers). Its library registers the row cursor or parameter sink with the runtime
(velt_text::rows_cursor::open_cursor / open_sink) and returns the handle; std looks it up, so
the Velt side never holds an address that a wrong number could stand in for.
Wrap handles in a class that releases them in [Symbol.dispose](), so using and the end of
the object's life close them (see packages/sqlite/src/lib.vlt).
Building and publishing
While you work on the package, any program that depends on it by path builds the crate with
cargo when needed, into the package's own target/velt-native/ (where the package really is,
also when the path goes through a symbolic link), so the programs that share the package share
the build. The Velt repository's database drivers in packages/ are such packages; its examples
and benchmarks depend on them by path. To publish:
velt native build # this machine's target → target/velt-native/<triple>/
velt native build --target aarch64-apple-darwin # another target your toolchain can build
velt publish # the package plus one library per targetA release build links the library's prelinked object into the executable (Linux and macOS),
with the system libraries the crate needs: velt native build records the ones rustc lists for
it (what its dependencies declare, e.g. a framework on macOS), and only programs that use the
package link them.
velt publish needs the crate's Cargo.lock (commit it) and a library for every target in
native.targets. Build the others on machines for those platforms (a CI matrix), collect their target/velt-native/<triple>/
directories into one directory, and pass it with velt publish --native-artifacts <dir>. A
target can be added to a published version later (velt publish --native-only); a published
library is never replaced.
Using the package
velt add greetvelt add downloads the library for your machine, checks it against the checksum recorded in
velt.lock.json (which pins the library of every published target), and lists the packages that run
native code. Then velt run, velt build --release (a self-contained executable) and velt dev
(the library is loaded into the running program; Velt edits still hot-swap) work as usual.