WebAssembly fora do navegador: WASI e Wasm no servidor

1. Visão geral

WebAssembly (Wasm) foi criado para executar código de alta performance no navegador. Mas suas características (compacto, sandbox rigoroso, independente de arquitetura de CPU) também são muito atrativas no lado do servidor.

Neste artigo, você entenderá o que é WASI e por que o Wasm no lado do servidor está chamando atenção. Você construirá um módulo Wasm, executará via runtime de linha de comando, o incorporará em uma aplicação Java 21 e escreverá testes. No final, os casos em que não deve usar Wasm.

2. Por que WebAssembly precisa de WASI?

2.1. Wasm “puro” não consegue fazer nada com o mundo exterior

Um módulo Wasm puro só sabe fazer computação na sua memória linear. Ele não consegue abrir arquivos, ler variáveis de ambiente, obter a hora do sistema ou abrir sockets. Toda interação com o mundo exterior deve passar por funções fornecidas pelo host (ambiente de execução do módulo) na forma de imports.

No navegador, o host é JavaScript. Fora do navegador, não há um host unificado. Se cada runtime definir seu próprio conjunto de APIs, um módulo compilado para um runtime não funcionará em outro.

2.2. WASI: interface de sistema padrão para Wasm

WASI (WebAssembly System Interface) é um conjunto de APIs padrão para operações tipo sistema operacional: arquivos, relógio, aleatório, variáveis de ambiente, argumentos de linha de comando, e em versões mais recentes, rede. Um módulo compilado visando WASI pode rodar em qualquer runtime que o suporte, como Wasmtime, WasmEdge, Wasmer, ou runtimes embutidos na sua aplicação.

A diferença mais importante é o modelo de capability. O módulo não tem permissão alguma por padrão. O host deve conceder permissões explicitamente, como “pode ler o diretório ./data”. Sem permissão, a chamada falha. Isso é o oposto de um processo comum, que herda todas as permissões do usuário que o executa.

2.3. WASI Preview 1, Preview 2 e Component Model

WASI passou por várias etapas. Você encontrará dois nomes comuns:

  • Preview 1 (wasip1): APIs de estilo função C, com suporte mais amplo.
  • Preview 2 (wasip2): construído sobre o Component Model. A interface é descrita usando uma linguagem IDL chamada WIT. Componentes escritos em diferentes linguagens podem se chamar mutuamente com tipos de dados mais ricos, como string, record, list, em vez de apenas inteiros.

O nível de suporte varia conforme o runtime e toolchain. Antes de escolher, verifique a documentação do runtime que pretende usar.

3. Por que Wasm no lado do servidor está chamando atenção?

Existem quatro razões principais:

  1. Sandbox por padrão: O módulo não consegue acessar memória fora de sua zona e não tem permissões de sistema sem concessão explícita. Perfeito para executar código não confiável, como plugins de clientes ou scripts enviados pelos usuários.
  2. Inicialização rápida, footprint pequeno: Os módulos são tipicamente leves e inicializam muito mais rápido que um container ou uma JVM nova. Por isso Wasm é adequado para serverless e edge, onde cada requisição pode precisar de sua própria instância.
  3. Portável: Um arquivo .wasm funciona em x86 ou ARM, Linux ou macOS, desde que haja um runtime.
  4. Multilíngue: Rust, C/C++, Go, Zig, AssemblyScript… podem compilar para Wasm. Sua aplicação Java pode carregar lógica escrita em outras linguagens sem precisar de JNI.

Comparação rápida com container:

Critério Container Wasm + WASI
Unidade de empacotamento Imagem contendo todo o userland do SO Um arquivo .wasm
Isolamento Namespaces/cgroups do kernel Sandbox do runtime + capability
Dependência de arquitetura Imagem necessária por arquitetura Não
Ecossistema Muito maduro Em desenvolvimento

Wasm não substitui container. Os dois geralmente se complementam: você ainda pode executar um runtime Wasm dentro de um container.

4. Construir e executar um programa WASI

Exemplo usa Rust porque seu toolchain Wasm é bastante conveniente. A mesma ideia se aplica a outras linguagens.

4.1. Programa para ler arquivo de pedidos

// src/main.rs
use std::fs;

fn main() {
    match fs::read_to_string("orders.csv") {
        Ok(content) => {
            let total: f64 = content
                .lines()
                .filter_map(|line| line.split(',').nth(1))
                .filter_map(|amount| amount.trim().parse::<f64>().ok())
                .sum();
            println!("Valor total do pedido: {total}");
        }
        Err(e) => eprintln!("Não conseguiu ler arquivo: {e}"),
    }
}

O programa lê orders.csv, extrai a segunda coluna (valor) e as soma. Nada especial no código para Wasm: std::fs será mapeado para chamadas WASI durante a compilação.

Adicione o target e faça o build:

rustup target add wasm32-wasip1
cargo build --release --target wasm32-wasip1

O resultado é o arquivo target/wasm32-wasip1/release/order-report.wasm (nome conforme seu package).

4.2. Executar com Wasmtime e ver o capability em ação

Crie o arquivo de dados:

ORD-1,120.5
ORD-2,79.5
ORD-3,300

Execute sem conceder permissão de diretório:

wasmtime run target/wasm32-wasip1/release/order-report.wasm
Não conseguiu ler arquivo: failed to find a pre-opened file descriptor through which "orders.csv" could be opened

O módulo executa mas não consegue ver arquivo algum. Não tem capability para o filesystem. Agora conceda permissão ao diretório atual:

wasmtime run --dir=. target/wasm32-wasip1/release/order-report.wasm
Valor total do pedido: 500

A flag --dir faz um “pre-open” do diretório para o módulo. Ele só vê aquele diretório, não consegue acessar /etc ou seu home. O conteúdo da mensagem de erro pode variar entre versões do runtime, mas o comportamento é o mesmo.

Nota: A sintaxe das flags (--dir, --env, mapeamento de diretório host para guest) pode variar entre versões do Wasmtime. Verifique wasmtime run --help na sua máquina.

5. Incorporar Wasm em aplicação Java

Wasm no lado do servidor não é só executar arquivo .wasm via CLI. Uma forma bem prática é incorporar o runtime na aplicação existente para rodar plugins ou lógica fornecida por terceiros.

Com Java, há duas abordagens:

  • Binding com runtimes nativos (Wasmtime, WasmEdge) via JNI ou FFM.
  • Usar um runtime escrito completamente em Java, como Chicory. Sem dependências nativas, é fácil empacotar e rodar em qualquer lugar que tenha JVM.

O exemplo abaixo usa Chicory.

5.1. Módulo para cálculo de taxa de envio

Suponha que um parceiro forneça a lógica de cálculo de frete como Wasm:

// src/lib.rs, Cargo.toml tem: [lib] crate-type = ["cdylib"]
#[no_mangle]
pub extern "C" fn shipping_fee(weight_grams: i32, distance_km: i32) -> i32 {
    let base = 15_000;
    let by_weight = (weight_grams / 500) * 5_000;
    let by_distance = distance_km * 1_000;
    base + by_weight + by_distance
}
rustup target add wasm32-unknown-unknown
cargo build --release --target wasm32-unknown-unknown
cp target/wasm32-unknown-unknown/release/shipping.wasm src/main/resources/

Este módulo não usa WASI. É apenas computação, então o target wasm32-unknown-unknown é suficiente e o módulo não precisa de imports.

5.2. Dependência Maven

<properties>
    <java.version>21</java.version>
    <!-- Verifique a versão mais recente no Maven Central -->
    <chicory.version>REPLACE_WITH_LATEST</chicory.version>
</properties>

<dependencies>
    <dependency>
        <groupId>com.dylibso.chicory</groupId>
        <artifactId>runtime</artifactId>
        <version>${chicory.version}</version>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Se usar Spring Boot 3.x, as versões de JUnit e AssertJ já são gerenciadas via spring-boot-starter-test.

5.3. Chamar função Wasm do Java

package dev.nonla.shipping;

import com.dylibso.chicory.runtime.ExportFunction;
import com.dylibso.chicory.runtime.Instance;
import com.dylibso.chicory.wasm.Parser;

import java.io.InputStream;

public class WasmShippingCalculator {

    private final ExportFunction shippingFee;

    public WasmShippingCalculator(String resource) {
        try (InputStream wasm = getClass().getResourceAsStream(resource)) {
            var module = Parser.parse(wasm);
            Instance instance = Instance.builder(module).build();
            this.shippingFee = instance.export("shipping_fee");
        } catch (Exception e) {
            throw new IllegalStateException("Não conseguiu carregar o módulo " + resource, e);
        }
    }

    public int fee(int weightGrams, int distanceKm) {
        long[] result = shippingFee.apply(weightGrams, distanceKm);
        return (int) result[0];
    }
}

Explicação:

  • Parser.parse(...) lê o bytecode Wasm em um modelo de módulo.
  • Instance.builder(module).build() cria uma instância com sua própria memória. O módulo não tem imports, então não é preciso fornecer funções de host.
  • instance.export("shipping_fee") obtém a função exportada. O nome deve corresponder ao #[no_mangle] em Rust.
  • apply(...) recebe e retorna long[], porque os tipos numéricos de Wasm (i32, i64…) são representados como long. Você faz o cast para int conforme necessário.

Teste rápido:

var calculator = new WasmShippingCalculator("/shipping.wasm");
System.out.println(calculator.fee(1_200, 10));
35000

O resultado é: 15.000 de taxa base, mais 2 × 5.000 por peso (1.200 / 500 = 2), mais 10 × 1.000 por distância.

5.4. Testar comportamento

package dev.nonla.shipping;

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class WasmShippingCalculatorTest {

    static WasmShippingCalculator calculator;

    @BeforeAll
    static void setUp() {
        calculator = new WasmShippingCalculator("/shipping.wasm");
    }

    @Test
    void givenLightParcelNearby_whenFee_thenOnlyBaseAndDistance() {
        assertThat(calculator.fee(300, 2)).isEqualTo(17_000);
    }

    @Test
    void givenHeavyParcelFarAway_whenFee_thenAllComponentsAdded() {
        assertThat(calculator.fee(2_000, 50)).isEqualTo(15_000 + 20_000 + 50_000);
    }

    @Test
    void givenMissingModule_whenCreate_thenFailFast() {
        assertThatThrownBy(() -> new WasmShippingCalculator("/not-found.wasm"))
            .isInstanceOf(IllegalStateException.class)
            .hasMessageContaining("not-found.wasm");
    }
}

O terceiro teste garante que a aplicação reporte o erro imediatamente na inicialização se o módulo estiver faltando, em vez de quebrar no primeiro request.

Nota: Uma Instance tem sua própria memória e estado. Não assuma que é thread-safe por padrão. Para um módulo puramente computacional, o mais simples é criar uma instância por request, ou usar um pool de instâncias. O módulo já parsed pode ser reutilizado para criar várias instâncias.

5.5. Quando o módulo precisa de WASI

Se o plugin precisar ler arquivos ou escrever em stdout, ele importará funções WASI. Você deve fornecer essas funções ao criar a instância. Chicory tem um módulo wasi específico para isso. O princípio continua sendo capability: forneça apenas os diretórios, variáveis de ambiente e stdout/stderr que o plugin realmente precisa. Não herde todo o ambiente do host por conveniência.

6. Padrões de aplicação prática

  • Sistema de plugins: Deixe clientes ou parceiros personalizarem a lógica (cálculo de preço, validação de regras, transformação de dados) sem executar seu código diretamente na JVM.
  • Policy engine: Empacote regras de autorização em módulos Wasm para compartilhar entre múltiplos serviços escritos em diferentes linguagens.
  • Serverless e edge functions: Cada requisição roda em uma instância isolada, com inicialização rápida.
  • Reutilizar bibliotecas nativas: Compile uma biblioteca C/Rust (parser, codec, algoritmo) para Wasm e chame do Java, evitando JNI e risco de crash na JVM.
  • Extensões para infraestrutura: Alguns proxies, bancos de dados e message brokers permitem estender via Wasm.

7. Quando NÃO usar

Wasm no lado do servidor é atraente, mas nem sempre é a escolha certa:

  • Lógica já bem escrita em Java e sem necessidade de isolamento: Adicionar uma camada Wasm só aumenta complexidade, overhead de transferência de dados e torna debug mais difícil.
  • Workload que precisa de multithreading pesado, GPU ou SIMD especializados: Suporte a threads e recursos de hardware em Wasm ainda são limitados e inconsistentes entre runtimes.
  • Aplicação que depende muito de rede e ecossistema de bibliotecas: Sockets, TLS, drivers de database via WASI ainda não são tão completos quanto em um SO real. Muitas bibliotecas ainda não conseguem compilar para Wasm.
  • Chamadas frequentes com grande volume de dados: Cada vez que você passa uma string ou array entre host e guest, deve ser copiada para a memória linear. Se chamar funções Wasm milhões de vezes com payloads grandes, esse custo é significativo. Agrupe as operações em chamadas maiores, menos frequentes.
  • Pensar que sandbox significa segurança absoluta: Wasm isola memória, mas não bloqueia loops infinitos ou consumo excessivo de CPU. Você ainda precisa de timeouts, limites de memória e mecanismos de interrupção se o runtime suportar (fuel, epoch interruption…).
  • Equipe não preparada: Toolchain, debugger e observabilidade para Wasm ainda estão em desenvolvimento. Não coloque em sistemas críticos só porque está “na moda”.

Alguns erros comuns:

  • Compilar para o target errado. Módulo que usa std::fs ou println! precisa do target WASI. Módulo puramente computacional deve usar wasm32-unknown-unknown.
  • Esquecer #[no_mangle] ou extern "C", fazendo a função não aparecer na lista de exports.
  • Confundir tipos: Wasm só tem tipos numéricos primitivos. Para passar strings, você precisa gerenciar a alocação na memória linear manualmente, ou usar Component Model com WIT.
  • Conceder permissões muito amplas (--dir=/, herdar todo o env), perdendo o maior benefício do WASI.

8. Conclusão

WASI transforma WebAssembly de uma tecnologia de navegador em um formato de execução portável, leve e com sandbox rigoroso para o lado do servidor. O modelo de capability (sem permissões a menos que sejam explicitamente concedidas) é a diferença fundamental em relação a processos e containers tradicionais.

Neste artigo, você construiu um programa WASI e viu como as permissões de filesystem são controladas. Você também incorporou um módulo Wasm em uma aplicação Java 21 usando um runtime puro de Java e escreveu testes para ele.

Considere Wasm quando precisar executar código não confiável, precisa de plugins multilíngues ou precisa de inicialização muito rápida. Se o problema é apenas lógica de negócio comum em um serviço Java, a solução mais simples geralmente continua sendo a melhor.

webassemblywasiwasmjavatech-trends