//// 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 }; " }