Source: handbook/ in the repository. Every code block compiles: CI runs ray check on each one.
Site with a React frontend
The notes as a website with a React + TypeScript frontend, a JSON API and the data in Redis. During development Vite serves the page with hot reload; in production the built frontend goes inside the binary, and a single executable serves the page, the assets and the API.
The complete project is in examples/apps/notes-web, with its
tests. The raylang blocks are copied from it and CI checks that they still are.
1. The project
ray new notes-web && cd notes-web
ray add web
ray add net
npm create vite@latest frontend -- --template react-ts
npm --prefix frontend install
[dependencies]
web = "^0.6.0"
net = "^0.10.0"
[frontend]
dev = "npm --prefix frontend run dev -- --strictPort --clearScreen false"
url = "http://localhost:5173"
build = "npm --prefix frontend run build"
dist = "frontend/dist"
The [frontend] section connects the program with Vite:
ray devstarts Vite next to the program and stops it on exit;ray build --nativerunsbuildand putsdistinside the binary.
Who serves what changes between development and production, but the browser always sees a single origin:
In development, with ray dev |
In production, the binary | |
|---|---|---|
| The page | Vite, at localhost:5173, with hot reload |
the program, from the embedded frontend |
/assets/… |
Vite | the program, with ETag and gzip |
/api/… |
Vite forwards it to the program | the program |
| Processes | two: Vite and the program | one |
Because the page and the API share an origin in both cases, there is no CORS to configure, and the frontend's relative URLs do not change.
net is added besides web because the program uses its Redis client directly.
2. The notes in Redis
Each note is a hash, note:<id>, with its title, body and date. A sorted set, notes:by_date,
keeps the ids by edit date: it is the index for listing from newest to oldest.
| Key | Type | Holds |
|---|---|---|
note:<id> |
hash | the fields title, body and updated_ms |
notes:by_date |
sorted set | the ids, with the edit date as the score |
You can look at them with redis-cli while the app runs:
redis-cli -p 56379 ZREVRANGE notes:by_date 0 -1 # the ids, newest first
redis-cli -p 56379 HGETALL note:<id> # one note
The pool hands each command to whichever connection is free, so two commands in a row are not a unit: another request could slip in between them. Writes that touch both keys go as a Lua script, and Redis runs a whole script without interruption:
// Writes the hash and its place in the index together.
const SAVE: string = `redis.call('HSET', KEYS[1], 'title', ARGV[2], 'body', ARGV[3], 'updated_ms', ARGV[4])
redis.call('ZADD', KEYS[2], ARGV[4], ARGV[1])
return 1`;
// Removes the hash and its place in the index together; 0 if it did not exist.
const REMOVE: string = `local n = redis.call('DEL', KEYS[1])
redis.call('ZREM', KEYS[2], ARGV[1])
return n`;
let _ = ok(
redis.pool_command_with(
p,
["EVAL", SAVE, "2", key(n.id), INDEX, n.id, n.title, n.body, to_string(now_ms)],
false
)?
)?;
The last argument of pool_command_with, false, turns off the automatic retry: a command that
writes must not be repeated blindly if the connection broke halfway.
Listing walks the index and reads each hash. The search filters in the program:
/// The newest notes (at most `limit`) whose title or body contains `query` (any case).
pub fn list(p: Pool, query: string, limit: int) -> Result<[Note], string> {
let ids = match (ok(redis.pool_command(p, ["ZREVRANGE", INDEX, "0", "-1"])?)?) {
Reply.Arr(items) => items,
_ => [],
};
let needle = query.trim().to_lower();
var out: [Note] = [];
for item in ids {
if (out.len() >= limit) {
break;
}
match (item) {
Reply.Str(id) => match (get(p, id)?) {
Option.Some(n) => {
if (needle == "" || (n.title + "\n" + n.body).to_lower().contains(needle)) {
out.push(n);
}
},
Option.None => { },
},
_ => { },
}
}
Result.Ok(out)
}
For a few thousand notes that is enough. With many more, Redis has search modules, or you move to Postgres as in the API chapter.
3. The frontend
The frontend is a normal React app. It talks to the program with fetch and relative URLs, which
work the same in development and in production:
// The JSON API of the raylang program. The same relative URLs work in development (Vite forwards
// /api) and in production (the program serves the page and the API from one origin).
export type Note = { id: string; title: string; body: string; updated_ms: number }
async function call<T>(method: string, url: string, body?: unknown): Promise<T> {
const res = await fetch(url, {
method,
headers: body === undefined ? {} : { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
})
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { error?: string }
throw new Error(err.error ?? `HTTP ${res.status}`)
}
return (res.status === 204 ? undefined : await res.json()) as T
}
export const listNotes = (q: string) => call<Note[]>('GET', `/api/notes?q=${encodeURIComponent(q)}`)
export const createNote = (title: string, body: string) => call<Note>('POST', '/api/notes', { title, body })
export const updateNote = (id: string, title: string, body: string) =>
call<Note>('PUT', `/api/notes/${id}`, { title, body })
export const deleteNote = (id: string) => call<void>('DELETE', `/api/notes/${id}`)
During development the page comes from Vite, on localhost:5173, and Vite forwards /api to the
program:
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// In development the page comes from Vite (hot reload) and /api goes to the raylang program,
// which `ray dev` runs on PORT (8080 by default).
export default defineConfig({
plugins: [react()],
server: {
proxy: { '/api': `http://127.0.0.1:${process.env.PORT ?? '8080'}` },
},
})
ray dev passes its environment to both processes, so PORT applies to the program and to the
proxy.
4. Serving the SPA from the binary
In production, the program serves the built files. Only /assets/ is mounted: a mount answers
every GET under its prefix before the routes see it, so mounting / would hide the API too.
// The built frontend: read from disk under `ray run`, baked into the native binary. Only
// /assets/ is mounted: a mount answers every GET under its prefix before the routes run, so
// mounting "/" would swallow /api too. The page itself comes from `spa`.
app.static_embedded("/assets/", "frontend/dist/assets");
app.GET("/", spa);
The page itself is served by spa. It also answers any path that is neither the API nor a file,
so a URL of the browser's router (/notes/123) works on reload:
// The single-page app: every GET that is not /api or a file gets index.html, so the browser's
// router (client-side URLs such as /notes/123) works on reload too.
fn spa(c: Ctx, r: Res) {
if (c.req.method != "GET" || c.req.path.starts_with("/api/")) {
fail(r, 404, "no such route");
return;
}
match (embed.read("frontend/dist/index.html")) {
Result.Ok(page) => r.status(200).html(from_utf8(page).unwrap_or("")),
Result.Err(_) => r.text("frontend not built: run `npm --prefix frontend run build`"),
}
}
app.gzip() compresses the assets: the Notes JavaScript goes from 222 KB to 69 KB.
The program's routes, all of them:
| Route | What it does | Answers |
|---|---|---|
GET /api/notes?q=…&limit=… |
lists and searches | 200, 503 |
POST /api/notes |
creates | 201, 422, 503 |
PUT /api/notes/:id |
replaces | 200, 404, 422, 503 |
DELETE /api/notes/:id |
deletes | 204, 404, 503 |
GET /assets/… |
the frontend's assets | 200, 304 |
any other GET |
index.html |
200 |
The last row is app.not_found(spa): whatever no route handles goes to the same function. The
API routes separate their endings with the type the store returns,
Result<Option<Note>, string>:
app.PUT("/api/notes/:id", fn(c: Ctx, r: Res) {
match (note_input(c)) {
Result.Err(e) => fail(r, 422, e),
Result.Ok(input) => {
let (title, body) = input;
match (store.save(db, c.param("id"), title, body, time.now())) {
Result.Ok(Option.Some(n)) => r.json(n.to_json()),
Result.Ok(Option.None) => fail(r, 404, "no such note"),
Result.Err(e) => fail(r, 503, e),
}
},
}
});
The 503 says a dependency failed, Redis, and not the request: a client can retry it.
5. Tests
The store tests run against a real Redis when NOTES_TEST_REDIS=1; otherwise they report that they
were skipped. This one checks that deleting also removes the index entry:
@test
fn deleting_also_leaves_the_index() {
let p = match (database()) {
Option.Some(p) => p,
Option.None => return,
};
let n = store.save(p, "", "Gone " + uuid.uuid_v4(), "", 1000).unwrap().unwrap();
let _ = store.remove(p, n.id).unwrap();
// The Lua script removed the hash and the index entry together.
match (redis.pool_command(p, ["ZSCORE", "notes:by_date", n.id]).unwrap()) {
redis.Reply.Nil => { },
other => panic("still in the index: " + redis.reply_str(other)),
}
redis.pool_close(p);
}
docker run -d --name notes-redis -p 127.0.0.1:56379:6379 redis:8-alpine
NOTES_TEST_REDIS=1 REDIS_PORT=56379 ray test
6. Develop and deploy
REDIS_PORT=56379 ray dev # opens http://localhost:5173
ray build --native --release -o notes-web
REDIS_PORT=56379 ./notes-web # http://127.0.0.1:8080: page, assets and API
The binary carries the built frontend, so it runs from any folder: the Notes one is about 3 MB. It
is configured with HOST, PORT, REDIS_HOST and REDIS_PORT.
| Variable | What for | Default |
|---|---|---|
HOST |
the address it listens on; 0.0.0.0 in a container |
127.0.0.1 |
PORT |
the port; in development the Vite proxy reads it too | 8080 |
REDIS_HOST |
where Redis is | 127.0.0.1 |
REDIS_PORT |
its port | 6379 |
At startup the program sends a PING to Redis and exits with code 1 if it does not answer,
instead of accepting requests that are going to fail. On SIGTERM it stops accepting, waits 5
seconds for the requests in flight and closes the pool, like the API.
Publishing a new version only takes replacing the binary: the frontend is inside, so the page and the API are never left on different versions.
Next step
From the browser to the terminal: a command-line tool, the smallest thing raylang ships.
<!-- sync: sha256:c2bb6ba0bd78 -->