kemal-vowrpc

Expose Vow services over HTTP with Kemal — typed RPC over per-procedure routes (GET reads, POST writes) in one line.

kemal-vowrpc

CI

Expose Vow services over HTTP with Kemal — typed RPC over per-procedure HTTP routes (GET for reads, POST for writes) in one line, with a typed client (TypeScript or JavaScript) that regenerates itself on every server restart.

Vow is transport-agnostic: it turns a (procedure name, JSON args) pair into a JSON result and leaves the wire to you. kemal-vowrpc is that wire for Kemal. It's a good-citizen plugin — it only adds the RPC endpoint to your app; your existing routes are untouched, and it leans on Kemal for everything HTTP rather than reinventing it.

A third-party shard, not affiliated with the kemalcr organization.

require "kemal-vowrpc"

module Demo
  class API
    include Vow::Exportable

    @[Vow::Export(verb: :get)]
    def greet(name : String) : String
      "Hello, #{name}!"
    end
  end
end

# Mount the RPC endpoint and keep a typed client in sync, all at once.
# `target:` picks the language (like `vow gen`); `client:` is the output stem.
Kemal::Vow.mount(Vow::Registry.new(Demo::API.new), at: "/rpc", target: "ts", client: "./client")
Kemal.run
# greet is a read (verb: :get) → its own URL, args in ?input=
curl -s 'localhost:3000/rpc/Demo/API/greet?input=%7B%22name%22%3A%22world%22%7D'  # => "Hello, world!"

Installation

dependencies:
  kemal-vowrpc:
    github: AristoRap/kemal-vowrpc

Then shards install. It pulls in vow and kemal.

Procedure names follow the Crystal FQN

A procedure's wire name is its fully-qualified Crystal name with ::., and the method leaf is camelCased — Vow computes this, vowrpc just passes it through. So Demo::API#greet is "Demo.API.greet" and Demo::API#find_user is "Demo.API.findUser"; the generated client nests them as api.Demo.API.greet / api.Demo.API.findUser. Argument keys are camelCased the same way (user_iduserId). (Override the whole name per method with @[Vow::Export(name: "custom")].)

Usage

Mount

Kemal::Vow.mount(registry, at: "/rpc")                                   # just the endpoint
Kemal::Vow.mount(registry, at: "/rpc", target: "ts", client: "./client") # + auto TS client (./client.ts)
Kemal::Vow.mount(registry, at: "/rpc", target: "js", client: "./client") # + auto JS client (./client.js + ./client.d.ts)
Kemal::Vow.mount(other, at: "/admin/rpc", introspect: false)             # mount as many as you like

mount registers one route per procedure (GET for verb: :get reads, POST otherwise) and, by default, a GET introspection route at the bare path, on Kemal's router; call it before Kemal.run. Full signature:

mount(registry, at:, client : String? = nil, target : String = "ts", generate : Symbol = :development, introspect : Bool = true)

Auto-generated client (TypeScript or JavaScript)

Pass client: an output stem and target: a language, and vowrpc generates the typed client in-process from the live registry at mount time — no vow binary, no recompile. Because mount runs at boot, the client refreshes on every server (re)start. This mirrors vow gen exactly: target: is the language, client: is --out.

  • target:"ts" (default) writes one <stem>.ts module; "js" writes a <stem>.js runtime plus its companion <stem>.d.ts (the types an editor reads beside the runtime, so a buildless browser import is still fully typed).
  • client: is a stem, not a filename — vowrpc appends the extension(s). A trailing client extension is tolerated and stripped, so "./client" and "./client.ts" both write ./client.ts under target: "ts".
  • generate::development (default; only when Kemal.config.env == "development"), :always, or :never.
  • Files are only rewritten when their contents change, so they won't churn your frontend's file-watcher.

Heads up on relative stems. A relative client: resolves against the process's working directory, not the source file — so crystal run example/server.cr with client: "./client" writes to the repo root, not example/. Join against __DIR__ (File.join(__DIR__, "client")) to pin it next to a known file.

The vow CLI (vow gen --entry app.cr --target js --out client) still exists for CI or out-of-server generation.

Wire contract

Each procedure gets its own URL under the mount path — the dotted procedure id becomes the path, mirroring Connect/gRPC/Twirp:

Demo::API#greet  →  Demo.API.greet  →  /rpc/Demo/API/greet

The HTTP verb comes from the method's @[Vow::Export(verb:)]:

  • verb: :get — a side-effect-free read. Routed as GET, with its args JSON-encoded in ?input= (/rpc/Calc/add?input=%7B%22a%22%3A2%2C%22b%22%3A3%7D). Because it's a GET, a browser/CDN/proxy can cache it.
  • verb: :post (the default) — everything else. Routed as POST, args in the JSON body keyed by argument name.

Response — the raw JSON result on success, or an error envelope:

Situation HTTP Body
success 200 the procedure's JSON result, e.g. "Hello, world!"
unknown procedure (route not registered) 404 Kemal's own not-found page
bad arg count / undecodable arg / method bad_input 400 {"error":"bad_input", "message":…, "hint":…}
Vow::Error with code unauthorized 401 error envelope
Vow::Error with code internal 500 error envelope
any other / custom Vow::Error code 400 error envelope

The error field carries Vow's stable string code and is the real contract; the HTTP status is a convenience mapping. An unknown procedure is now a plain Kemal 404 — its route was never registered — instead of the old "never 404" workaround the single-endpoint design needed.

Large read args: a GET puts args in the URL, which has a length ceiling. A read taking a big payload should be verb: :post (it just won't be HTTP-cacheable — the same trade-off Connect documents).

Caching is the app's job

vow only picks the verb; it never sets a caching policy. A verb: :get method is cacheable-capable, but a CDN won't cache it until something sets Cache-Control. Set it from the method via the Kemal context:

@[Vow::Export(verb: :get)]
def list(ctx : Kemal::Vow::Context) : Array(Todo)
  ctx.cache("public, max-age=60")   # or "private, no-store", etc.
  @items
end

…or blanket it for every read at the Kemal layer:

before_get "/rpc/*" { |env| env.response.headers["Cache-Control"] = "public, max-age=60" }

Per-browser vs. shared (CDN) caching. A max-age alone is honored by the caller's own browser — the second identical read never leaves the machine. To spare the origin across all users, put a CDN/reverse proxy in front and use the directives a shared cache understands:

  • public lets a shared cache store the response; private (e.g. for a per-user read like whoami) restricts it to the one browser.
  • s-maxage=N sets the shared/CDN TTL independently of the browser's max-age — e.g. public, max-age=60, s-maxage=3600 lets the CDN serve it for an hour while each browser refreshes every minute.
  • Because every read has its own URL (that's why per-procedure routing matters), the CDN keys on it cleanly — the origin then sees ~one request per procedure per TTL. Verify with the CDN's X-Cache: HIT/MISS header.

This is RPC's answer to origin-sparing reads (the same one tRPC and Connect use). Vow doesn't auto-ETag responses — for tiny JSON, max-age already skips the request entirely, which is strictly cheaper than a revalidating round-trip.

Request context (auth, headers, IP)

An exported method opts into the HTTP request by declaring a leading Vow::Context parameter — it's injected by the transport and stays out of the client signature and the manifest:

@[Vow::Export]
def me(ctx : Vow::Context) : String
  token = ctx["Authorization"]
  raise Vow::Error.unauthorized("missing token") unless token
  # …
end

ctx["Header-Name"] reads a request header. Declare Kemal::Vow::Context instead of the base type for richer accessors: #header, #method, #ip, #request, and — for a read — #cache(value) / #response_header(name, value) to set response headers (e.g. Cache-Control) and #response for the raw response.

Introspection

With introspect: true (default), GET <path> serves the registry's manifest as JSON; ?format= serves generated client text instead — ts (TypeScript module), js (JavaScript runtime), or dts (the .d.ts declaration). Handy for tooling and quick discovery:

curl -s localhost:3000/rpc              # => {"procedures":[…],"types":[…]}
curl -s 'localhost:3000/rpc?format=ts'  # => the generated TS module
curl -s 'localhost:3000/rpc?format=js'  # => the JS runtime
curl -s 'localhost:3000/rpc?format=dts' # => the .d.ts declaration

Frontend

Point the generated client at the mount base. Vow generates createHttpClient, so the common case is one line — it builds the per-procedure URL, picks GET (reads) or POST (writes) from the verb, and throws a typed VowError on the error envelope:

import { createHttpClient } from "./client";

const api = createHttpClient("/rpc");
// add auth: createHttpClient("/rpc", { headers: () => ({ Authorization: token() }) })

await api.Demo.API.greet({ name: "world" }); // Promise<string> — GET /rpc/Demo/API/greet?input=…

Need full control — batching, retries, websockets, auth refresh? The generated client also exports createClient(transport), the escape hatch createHttpClient is built on; bring your own (name, args, verb) => Promise<unknown> transport.

With target: "js", import the runtime from ./client.js instead; the generated client.d.ts sitting beside it gives your editor the same types with no build step.

Composes with your Kemal app

vowrpc only adds its routes — your handlers, middleware, and config are untouched. For cross-cutting HTTP concerns (CORS, body-size limits, request logging, gzip, auth gating) use Kemal middleware as you normally would — before_all, before_get "/rpc/*", use, or a community shard (kemal-session, kemal-basic-auth, a JWT shard). vowrpc deliberately doesn't reimplement what Kemal already provides. For per-method authorization, read the request inside the method via a leading Vow::Context param and raise Vow::Error.unauthorized.

Custom transports / testing

The HTTP wiring is a thin shell over a pure core, Kemal::Vow.dispatch_call, which takes the procedure name and its raw JSON args object and returns a Result (status + JSON body) — no HTTP context needed (pass an optional Vow::Context to exercise context-aware methods). Routing has already resolved the procedure from the URL, so this just runs the call:

result = Kemal::Vow.dispatch_call(registry, "Demo.API.greet", %({"name":"world"}))
result.status # => 200
result.body   # => %("Hello, world!")

Examples

A minimal one-file server is in examples/basic.cr:

crystal run examples/basic.cr   # serves http://localhost:3000/rpc

For a full-stack walkthrough — Crystal services plus a buildless, fully-typed JavaScript frontend that calls them with no npm/bundler/build step — see examples/vanilla/:

crystal run examples/vanilla/server.cr   # => http://localhost:3000 (UI + typed client)

For a Vite + Vue 3 SPA against the same services (TypeScript client), see examples/vue-spa/ and its NOTES.md.

They showcase typed object results (struct Todo → a TS interface), multiple services nested under one client, cacheable GET reads with per-method Cache-Control, and a context-aware method reading a request header.

Development

shards install
crystal spec

License

MIT

Repository

kemal-vowrpc

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 2
  • about 2 hours ago
  • May 31, 2026
License

MIT License

Links
Synced at

Sun, 31 May 2026 14:17:41 GMT

Languages