//// Star — idiomatic Lightspeed LiveView app served by Mist.
////
//// Stack:
//// - `endpoint` middleware + verified routes + `get_live` for `/`
//// - `stateful.LifecycleComponent` (see `star/live`)
//// - Lightspeed protocol over WebSocket at `/live` (see `star/ws`)
//// - browser runtime at `/assets/lightspeed.js` (Event / Diff / Ack)
import gleam/bit_array
import gleam/bytes_tree
import gleam/erlang/process
import gleam/http as gleam_http
import gleam/http/request as http_request
import gleam/http/response as http_response
import gleam/int
import gleam/io
import gleam/list
import gleam/option.{None}
import gleam/string
import lightspeed/component/stateful
import lightspeed/framework/controller
import lightspeed/framework/endpoint
import lightspeed/framework/http as ls_http
import lightspeed/framework/verified_routes
import lightspeed/transport/contract
import mist
import star/live
import star/ws
const port = 8080
// ── Endpoint ─────────────────────────────────────────────────────────────────
/// Framework endpoint: live index, health, static assets.
pub fn app() -> endpoint.Endpoint {
let index = verified_routes.route0("/")
let health = verified_routes.route0("/health")
endpoint.new(contract.allow_all("owner-1"), "/live")
|> endpoint.pipe(fn(conn) {
ls_http.put_header(conn, "x-pipeline", "enabled")
})
|> endpoint.get_live(index, "star_view", fn(_conn) { initial_body() })
|> endpoint.get_controller(health, fn(conn) { controller.text(conn, "ok") })
|> endpoint.static("/assets/app.css", "text/css; charset=utf-8", app_css())
|> endpoint.static(
"/assets/lightspeed.js",
"application/javascript; charset=utf-8",
lightspeed_client(),
)
}
/// Disconnected LiveView body. `wisp_html` wraps this in `#app` + `data-ls-*`.
fn initial_body() -> String {
let context = stateful.mount_context("star-root", "/", stateful.Disconnected)
let #(instance, _commands, _patches) =
stateful.start(live.definition(), context, live.Assigns, live.target)
stateful.html(instance)
}
// ── Mist adapter ─────────────────────────────────────────────────────────────
fn to_ls_method(method: gleam_http.Method) -> ls_http.Method {
case method {
gleam_http.Get -> ls_http.Get
gleam_http.Post -> ls_http.Post
gleam_http.Put -> ls_http.Put
gleam_http.Patch -> ls_http.Patch
gleam_http.Delete -> ls_http.Delete
gleam_http.Head -> ls_http.Head
gleam_http.Options -> ls_http.Options
gleam_http.Trace -> ls_http.Other("TRACE")
gleam_http.Connect -> ls_http.Other("CONNECT")
gleam_http.Other(label) -> ls_http.Other(label)
}
}
fn to_ls_request(req: http_request.Request(mist.Connection)) -> ls_http.Request {
ls_http.request(
method: to_ls_method(req.method),
path: req.path,
headers: req.headers,
body: "",
session_id: "session-1",
csrf_token: "token-1",
origin: "http://localhost:" <> int.to_string(port),
session: [],
flash: [],
)
}
fn to_http_response(
response: ls_http.Response,
) -> http_response.Response(mist.ResponseData) {
let resp =
http_response.new(response.status)
|> http_response.set_body(mist.Bytes(bytes_tree.from_string(response.body)))
list.fold(response.headers, resp, fn(resp, header) {
http_response.set_header(resp, header.0, header.1)
})
}
/// Inject stylesheet + client script into the Lightspeed HTML shell.
fn enhance_live_html(body: String) -> String {
body
|> string.replace(
"",
""
<> ""
<> ""
<> "",
)
|> string.replace("
Lightspeed", "Star")
}
fn response_body_string(
response: http_response.Response(mist.ResponseData),
) -> Result(String, Nil) {
case response.body {
mist.Bytes(tree) ->
tree
|> bytes_tree.to_bit_array
|> bit_array.to_string
_ -> Error(Nil)
}
}
fn is_websocket_upgrade(req: http_request.Request(mist.Connection)) -> Bool {
case http_request.get_header(req, "upgrade") {
Ok(value) -> string.lowercase(value) == "websocket"
Error(_) -> False
}
}
fn handle_request(
req: http_request.Request(mist.Connection),
) -> http_response.Response(mist.ResponseData) {
case req.path, req.method, is_websocket_upgrade(req) {
"/live", gleam_http.Get, True -> upgrade_live(req)
_, _, _ -> {
let response =
req
|> to_ls_request
|> endpoint.call(app(), _)
|> to_http_response
case response.status, http_response.get_header(response, "content-type") {
200, Ok(ct) ->
case string.contains(ct, "text/html") {
True ->
case response_body_string(response) {
Ok(body) ->
response
|> http_response.set_body(
mist.Bytes(bytes_tree.from_string(enhance_live_html(body))),
)
Error(_) -> response
}
False -> response
}
_, _ -> response
}
}
}
}
fn upgrade_live(
req: http_request.Request(mist.Connection),
) -> http_response.Response(mist.ResponseData) {
mist.websocket(
request: req,
on_init: fn(conn) {
let #(session, frames) = ws.mount("/")
ws.push_frames(conn, frames)
#(session, None)
},
handler: fn(session, message, conn) {
case message {
mist.Text(payload) -> {
let #(next, outbound) = ws.handle_frame(session, payload)
ws.push_frames(conn, outbound)
mist.continue(next)
}
mist.Binary(_) -> mist.continue(session)
mist.Closed | mist.Shutdown -> mist.stop()
mist.Custom(_) -> mist.continue(session)
}
},
on_close: fn(_state) { Nil },
)
}
// ── Main ─────────────────────────────────────────────────────────────────────
pub fn main() -> Nil {
let assert Ok(_) =
mist.new(handle_request)
|> mist.port(port)
|> mist.start
io.println("Star (Lightspeed) at http://localhost:" <> int.to_string(port))
io.println(" GET / live view")
io.println(" WS /live lightspeed protocol")
io.println(" GET /health ok")
io.println(" GET /assets/* css + client")
process.sleep_forever()
}
// ── Assets ───────────────────────────────────────────────────────────────────
fn app_css() -> String {
":root {
color-scheme: light;
--bg: #ffffff;
--bg-alt: #f6f5f4;
--ink: rgba(0, 0, 0, 0.95);
--ink-secondary: #615d59;
--ink-tertiary: #a39e98;
--accent: #0075de;
--accent-active: #005bab;
--border: rgba(0, 0, 0, 0.1);
--shadow-card:
rgba(0, 0, 0, 0.04) 0px 4px 18px,
rgba(0, 0, 0, 0.027) 0px 2.025px 7.85px,
rgba(0, 0, 0, 0.02) 0px 0.8px 2.93px,
rgba(0, 0, 0, 0.01) 0px 0.175px 1.04px;
--radius: 12px;
--radius-sm: 4px;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
color: var(--ink);
background: var(--bg);
line-height: 1.5;
}
.layout {
width: min(720px, calc(100vw - 32px));
margin: 0 auto;
padding: 56px 0 80px;
}
header { margin-bottom: 12px; }
h1 {
margin: 0;
font-size: 1.625rem;
font-weight: 700;
letter-spacing: -0.025em;
}
.tagline {
margin: 6px 0 36px;
color: var(--ink-secondary);
font-size: 0.9375rem;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.card {
padding: 16px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
}
.card-label {
display: block;
margin-bottom: 12px;
font-size: 0.75rem;
font-weight: 600;
color: var(--ink-tertiary);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.card-foot {
display: block;
margin-top: 12px;
font-size: 0.875rem;
color: var(--ink-secondary);
}
.card-foot strong { color: var(--ink); font-weight: 600; }
.counter-display {
text-align: center;
padding: 16px 0 4px;
font-size: 3rem;
font-weight: 700;
line-height: 1;
font-variant-numeric: tabular-nums;
letter-spacing: -0.03em;
}
.counter-controls { display: flex; gap: 6px; }
.counter-controls button { flex: 1; text-align: center; font-size: 1rem; font-weight: 500; padding: 8px; }
.panel {
margin-top: 10px;
padding: 12px;
border-radius: var(--radius-sm);
background: var(--bg-alt);
border: 1px solid var(--border);
}
.panel-text { margin: 0; font-size: 0.875rem; line-height: 1.6; color: var(--ink-secondary); }
.btn {
display: inline-flex; align-items: center; justify-content: center;
width: 100%; border: 1px solid var(--border); border-radius: var(--radius-sm);
padding: 8px 14px; background: var(--bg); color: var(--ink);
font: 500 0.875rem/1 'Inter', system-ui, sans-serif; cursor: pointer;
}
.btn:hover { background: var(--bg-alt); }
.btn:active { transform: scale(0.97); }
.btn-primary {
display: inline-flex; align-items: center; justify-content: center;
width: 100%; border: 0; border-radius: var(--radius-sm);
padding: 10px 20px; background: var(--accent); color: #fff;
font: 600 0.9375rem/1 'Inter', system-ui, sans-serif; cursor: pointer;
margin-top: 10px;
}
.btn-primary:hover { background: var(--accent-active); }
label { display: block; margin-bottom: 6px; font-size: 0.875rem; font-weight: 500; }
input, textarea {
display: block; width: 100%; padding: 10px 12px;
border: 1px solid #ddd; border-radius: var(--radius-sm);
background: var(--bg); color: var(--ink);
font: 400 0.9375rem/1.5 'Inter', system-ui, sans-serif; outline: none;
}
input:focus, textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(0, 117, 222, 0.12);
}
textarea { resize: vertical; }
.muted { color: var(--ink-tertiary); font-size: 0.9375rem; margin: 0; }
.flash {
margin: 0 0 10px;
padding: 8px 10px;
background: rgba(0, 117, 222, 0.08);
border-radius: var(--radius-sm);
font-size: 0.875rem;
color: var(--accent-active);
}
.note-form { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
.notes-list { display: flex; flex-direction: column; gap: 6px; }
.note-item {
padding: 12px 14px; background: var(--bg-alt);
border: 1px solid var(--border); border-radius: 8px;
}
.note-head {
display: flex; align-items: center; justify-content: space-between;
gap: 8px; margin-bottom: 2px;
}
.note-item strong { font-size: 0.9375rem; font-weight: 600; }
.note-item p { margin: 0; color: var(--ink-secondary); font-size: 0.875rem; line-height: 1.5; }
.note-delete {
width: auto; padding: 5px 12px; font-size: 0.8125rem; font-weight: 500;
border-radius: 4px; cursor: pointer; background: transparent;
color: #dd5b00; border: 1px solid rgba(221, 91, 0, 0.2);
}
.note-delete:hover { background: rgba(221, 91, 0, 0.06); }
.links {
margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--border);
font-size: 0.8125rem; color: var(--ink-tertiary);
}
#app.ls-connected { outline: 2px solid rgba(0, 117, 222, 0.15); outline-offset: 4px; }
@media (max-width: 640px) {
.layout { padding-top: 24px; }
.grid { grid-template-columns: 1fr; }
}
"
}
/// Minimal Lightspeed browser runtime: connect, push events, apply Replace diffs.
fn lightspeed_client() -> String {
"const ROOT = document.getElementById('app');
if (!ROOT) throw new Error('Lightspeed: #app missing');
const wsPath = ROOT.dataset.lsWs || '/live';
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const url = proto + '://' + location.host + wsPath;
let socket;
let refSeq = 1;
function escapeField(value) {
return String(value).replace(/\\\\/g, '\\\\\\\\').replace(/\\|/g, '\\\\|');
}
function encodeEvent(name, payload) {
const ref = String(refSeq++);
return ['event', ref, name, payload || ''].map(escapeField).join('|');
}
function splitFields(payload) {
const fields = [];
let cur = '';
let esc = false;
for (const ch of payload) {
if (esc) { cur += ch; esc = false; continue; }
if (ch === '\\\\') { esc = true; continue; }
if (ch === '|') { fields.push(cur); cur = ''; continue; }
cur += ch;
}
fields.push(cur);
return fields;
}
function applyReplace(target, html) {
const el = document.querySelector(target);
if (!el) return;
if (target === '#app' || el.id === 'app') {
el.innerHTML = html;
} else {
el.outerHTML = html;
}
bindClicks(ROOT);
}
function applyPatchStream(encoded) {
const fields = splitFields(encoded);
if (fields[0] !== 'ps') return;
const version = Number(fields[1]);
if (version !== 1) return;
const dictLen = Number(fields[2]);
const dict = fields.slice(3, 3 + dictLen);
let i = 3 + dictLen;
const opCount = Number(fields[i++]);
for (let n = 0; n < opCount; n++) {
const op = fields[i++];
if (!op) break;
// tokens joined with commas inside the op field: r,targetIdx,htmlIdx
const tokens = op.split(',');
if (tokens[0] === 'r') {
const target = dict[Number(tokens[1])];
const html = dict[Number(tokens[2])];
applyReplace(target, html);
}
}
}
function onFrame(text) {
const fields = splitFields(text);
const tag = fields[0];
if (tag === 'hello') {
ROOT.classList.add('ls-connected');
return;
}
if (tag === 'diff') {
applyPatchStream(fields[2] || '');
const pref = fields[1] || '';
if (socket && socket.readyState === 1 && pref) {
socket.send(['ack', pref].map(escapeField).join('|'));
}
}
}
function collectFormPayload(extra) {
const params = new URLSearchParams();
ROOT.querySelectorAll('input[name], textarea[name]').forEach((el) => {
params.set(el.name, el.value || '');
});
if (extra) {
Object.entries(extra).forEach(([k, v]) => params.set(k, v));
}
return params.toString();
}
function pushEvent(name, extra) {
if (!socket || socket.readyState !== 1) return;
let payload = '';
if (name === 'greet' || name === 'save_note') {
payload = collectFormPayload(extra);
} else if (name === 'delete_note' && extra && extra.id != null) {
payload = 'id=' + encodeURIComponent(String(extra.id));
}
socket.send(encodeEvent(name, payload));
}
function bindClicks(root) {
root.querySelectorAll('[data-ls-click]').forEach((el) => {
if (el.__lsBound) return;
el.__lsBound = true;
el.addEventListener('click', (ev) => {
ev.preventDefault();
const name = el.getAttribute('data-ls-click');
const value = el.getAttribute('data-ls-value');
const extra = value != null ? { id: value } : null;
pushEvent(name, extra);
});
});
}
function connect() {
socket = new WebSocket(url);
socket.addEventListener('message', (ev) => onFrame(String(ev.data)));
socket.addEventListener('close', () => {
ROOT.classList.remove('ls-connected');
setTimeout(connect, 1000);
});
socket.addEventListener('error', () => { try { socket.close(); } catch (_) {} });
}
bindClicks(ROOT);
connect();
window.__lightspeed = { pushEvent };
"
}