Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Platforms

One language, several runtimes. A package builds for Node (the default), Deno, Bun, or the browser: set target in vilan.toml, or pass --platform on the CLI.

The standard library is layered so each build only uses what its platform can do. Call a server function from code a browser build can reach, and you get a clear compile error naming the call chain, not a runtime crash. That’s the whole idea of this chapter.

The std layers

  • Base: platform-neutral, available everywhere (collections, Option/Result, strings, numbers, reactive, shared, time, json/wire/binary, the rpc client machinery, style, fetch, crypto, and friends).
  • Browser layer: std::dom, std::ui, std::router, std::storage. Browser builds only.
  • Process layer (Node/Deno/Bun): std::db, std::http, std::fs, std::process, std::rpc_server. Server builds only.

Going deeper. The check is on reachable code, not on imports. A file may import std::fs and compile for the browser, as long as no code the browser entry can reach actually calls into it. The compiler colors every function with the platforms it can run on (seeded by the std layers, flowing through calls, the same way async is inferred), and checks the colors only along paths that start at your main. When a path crosses onto the wrong platform, the error shows that path. Module-level lets follow the same rule: a binding’s initializer runs (and is checked, and is bundled) only if something reachable references it: a server-only global in a shared file costs the browser build nothing. const initializers run at build time and ship as plain values, so they never color anything.

Full-stack packages

A client + server app fits in one package with two entries: each [entry.<name>] names an entry file and the platform it builds for. This is the default shape; reach past it only when you have a reason:

[package]
name = "app"

[entry.client]
target = "browser"

[entry.server]
# target defaults to node; path defaults to <name>.vl under src/
app/
  vilan.toml
  src/
    client.vl     the browser entry
    server.vl     the node entry
    store.vl      the service, next to its resources
    todo.vl       shared types — anything both entries import

vilan build compiles every entry for its own target into dist/<name>.mjs for a process entry, <name>.js for a browser one (browser entries first, so a server that ships bundles finds them fresh); vilan run builds everything and starts the one Node entry; vilan check checks all entries, always. Reachability does the sorting: the same store.vl may use std::fs freely, because only the server entry reaches into it. If client code ever calls that far, the build fails with the call chain.

The advanced form, for larger apps, is a workspace of packages (a shared [library] for payload types, a browser package, a server package), where each member has its own manifest and dependency set, and may declare its own entries:

app/
  vilan.toml       [project] packages = ["common", "client", "server"]
  common/          [library] — payload types (base layer only)
  client/          [package] target = "browser"
  server/          [package] (node)

vilan build . at the root builds every member the same way. The compiler checks each against its own platform, including that common stays platform-neutral. In either shape, the service lives next to its resources (see Services).

Going deeper. Where a team wants an explicit boundary, [platform("browser")] on a function declares the platforms it promises to run on. The compiler checks the promise on every compile (entry or not, whatever the build target), and a violation lands at the fence with its chain instead of at some distant entry in a dependent build. Patterns use the manifest layers’ vocabulary: "node", "browser", families like "@process", or several at once for code that must stay neutral. The editor shows the same information as you write: violations appear as live diagnostics at the offending call, and hovering a function shows its inferred requirement and how it got it, e.g. requires the `process` layer of `std` (via `save → write_file (std::fs)`).

Externs: talking to the host

You’ll mostly consume host bindings through std. But when you need a Node API or browser API that std doesn’t wrap yet, you can bind it yourself with an extern declaration. This is how std’s own bindings are written:

// A function from a host module (node:crypto):
[extern("node:crypto", "randomBytes")]
external fun random_bytes_sync(length: i32): HashBuffer;

// An opaque host object, with methods bound one by one:
external struct HashBuffer;
impl HashBuffer {
	[extern(method, "toString")]
	external fun to_string_encoded(self, encoding: str): str;
}

// An async host function — promise-returning; callers implicitly await:
[extern("node:timers/promises", "setTimeout")]
async external fun sleep(ms: i32): void;

The binding forms:

FormBinds
[extern("module", "name")]an import from a host module
[extern("global.path")]a dotted global, like history.pushState
[extern(method, "name")]a method on a host object
[extern(method)]the same, taking the host name from the vilan function’s own
[extern(get, "prop")] / [extern(set, "prop")]a property read / write
[extern(new, "Class")]construction — new Class(…), which a host constructor requires
[extern(new, "module", "Class")]the same, on a class imported from a host module

Any of them takes a trailing retains[extern(method, "addEventListener", retains)] — and you need it whenever the host keeps what you hand it instead of reading it only until the call returns: an event listener, a queued callback, a stashed value. Without the flag the compiler assumes the host is done at the call and may destroy the argument’s binding at its last use, which the host then reads after the fact. The memory chapter states what the flag promises.

Keep externs in platform-specific packages (they are host-specific by nature). When a binding proves itself, consider promoting it into std rather than copying it between apps.

Assets

Browser builds produce <entry>.js, plus <entry>.css when styles were emitted. Your server serves those two files and an HTML shell; the services guide shows the standard fallback shape.

Going deeper. Build assets come from std::asset::emit(kind, content) — or emit_keyed(kind, key, content), where the contribution carries its own sort key — callable only during const evaluation; the input sibling std::asset::read(path) pulls a project file in at compile time the same way. The styling system’s const style() chains call it to write CSS rules. Libraries can also declare platform overlays of their own (a base root plus per-platform roots in [library.layer]), which is how std itself is layered; most libraries never need this.