Native Lightspeed LiveView app on Mist (Gleam)
0

Configure Feed

Select the types of activity you want to include in your feed.

star / src / star.gleam
16 kB 508 lines
1//// Star — idiomatic Lightspeed LiveView app served by Mist. 2//// 3//// Stack: 4//// - `endpoint` middleware + verified routes + `get_live` for `/` 5//// - `stateful.LifecycleComponent` (see `star/live`) 6//// - Lightspeed protocol over WebSocket at `/live` (see `star/ws`) 7//// - browser runtime at `/assets/lightspeed.js` (Event / Diff / Ack) 8 9import gleam/bit_array 10import gleam/bytes_tree 11import gleam/erlang/process 12import gleam/http as gleam_http 13import gleam/http/request as http_request 14import gleam/http/response as http_response 15import gleam/int 16import gleam/io 17import gleam/list 18import gleam/option.{None} 19import gleam/string 20import lightspeed/component/stateful 21import lightspeed/framework/controller 22import lightspeed/framework/endpoint 23import lightspeed/framework/http as ls_http 24import lightspeed/framework/verified_routes 25import lightspeed/transport/contract 26import mist 27import star/live 28import star/ws 29 30const port = 8080 31 32// ── Endpoint ───────────────────────────────────────────────────────────────── 33 34/// Framework endpoint: live index, health, static assets. 35pub fn app() -> endpoint.Endpoint { 36 let index = verified_routes.route0("/") 37 let health = verified_routes.route0("/health") 38 39 endpoint.new(contract.allow_all("owner-1"), "/live") 40 |> endpoint.pipe(fn(conn) { 41 ls_http.put_header(conn, "x-pipeline", "enabled") 42 }) 43 |> endpoint.get_live(index, "star_view", fn(_conn) { initial_body() }) 44 |> endpoint.get_controller(health, fn(conn) { controller.text(conn, "ok") }) 45 |> endpoint.static("/assets/app.css", "text/css; charset=utf-8", app_css()) 46 |> endpoint.static( 47 "/assets/lightspeed.js", 48 "application/javascript; charset=utf-8", 49 lightspeed_client(), 50 ) 51} 52 53/// Disconnected LiveView body. `wisp_html` wraps this in `#app` + `data-ls-*`. 54fn initial_body() -> String { 55 let context = stateful.mount_context("star-root", "/", stateful.Disconnected) 56 let #(instance, _commands, _patches) = 57 stateful.start(live.definition(), context, live.Assigns, live.target) 58 stateful.html(instance) 59} 60 61// ── Mist adapter ───────────────────────────────────────────────────────────── 62 63fn to_ls_method(method: gleam_http.Method) -> ls_http.Method { 64 case method { 65 gleam_http.Get -> ls_http.Get 66 gleam_http.Post -> ls_http.Post 67 gleam_http.Put -> ls_http.Put 68 gleam_http.Patch -> ls_http.Patch 69 gleam_http.Delete -> ls_http.Delete 70 gleam_http.Head -> ls_http.Head 71 gleam_http.Options -> ls_http.Options 72 gleam_http.Trace -> ls_http.Other("TRACE") 73 gleam_http.Connect -> ls_http.Other("CONNECT") 74 gleam_http.Other(label) -> ls_http.Other(label) 75 } 76} 77 78fn to_ls_request(req: http_request.Request(mist.Connection)) -> ls_http.Request { 79 ls_http.request( 80 method: to_ls_method(req.method), 81 path: req.path, 82 headers: req.headers, 83 body: "", 84 session_id: "session-1", 85 csrf_token: "token-1", 86 origin: "http://localhost:" <> int.to_string(port), 87 session: [], 88 flash: [], 89 ) 90} 91 92fn to_http_response( 93 response: ls_http.Response, 94) -> http_response.Response(mist.ResponseData) { 95 let resp = 96 http_response.new(response.status) 97 |> http_response.set_body(mist.Bytes(bytes_tree.from_string(response.body))) 98 99 list.fold(response.headers, resp, fn(resp, header) { 100 http_response.set_header(resp, header.0, header.1) 101 }) 102} 103 104/// Inject stylesheet + client script into the Lightspeed HTML shell. 105fn enhance_live_html(body: String) -> String { 106 body 107 |> string.replace( 108 "</head>", 109 "<link rel=\"stylesheet\" href=\"/assets/app.css\">" 110 <> "<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">" 111 <> "<script type=\"module\" src=\"/assets/lightspeed.js\"></script>" 112 <> "</head>", 113 ) 114 |> string.replace("<title>Lightspeed</title>", "<title>Star</title>") 115} 116 117fn response_body_string( 118 response: http_response.Response(mist.ResponseData), 119) -> Result(String, Nil) { 120 case response.body { 121 mist.Bytes(tree) -> 122 tree 123 |> bytes_tree.to_bit_array 124 |> bit_array.to_string 125 _ -> Error(Nil) 126 } 127} 128 129fn is_websocket_upgrade(req: http_request.Request(mist.Connection)) -> Bool { 130 case http_request.get_header(req, "upgrade") { 131 Ok(value) -> string.lowercase(value) == "websocket" 132 Error(_) -> False 133 } 134} 135 136fn handle_request( 137 req: http_request.Request(mist.Connection), 138) -> http_response.Response(mist.ResponseData) { 139 case req.path, req.method, is_websocket_upgrade(req) { 140 "/live", gleam_http.Get, True -> upgrade_live(req) 141 142 _, _, _ -> { 143 let response = 144 req 145 |> to_ls_request 146 |> endpoint.call(app(), _) 147 |> to_http_response 148 149 case response.status, http_response.get_header(response, "content-type") { 150 200, Ok(ct) -> 151 case string.contains(ct, "text/html") { 152 True -> 153 case response_body_string(response) { 154 Ok(body) -> 155 response 156 |> http_response.set_body( 157 mist.Bytes(bytes_tree.from_string(enhance_live_html(body))), 158 ) 159 Error(_) -> response 160 } 161 False -> response 162 } 163 _, _ -> response 164 } 165 } 166 } 167} 168 169fn upgrade_live( 170 req: http_request.Request(mist.Connection), 171) -> http_response.Response(mist.ResponseData) { 172 mist.websocket( 173 request: req, 174 on_init: fn(conn) { 175 let #(session, frames) = ws.mount("/") 176 ws.push_frames(conn, frames) 177 #(session, None) 178 }, 179 handler: fn(session, message, conn) { 180 case message { 181 mist.Text(payload) -> { 182 let #(next, outbound) = ws.handle_frame(session, payload) 183 ws.push_frames(conn, outbound) 184 mist.continue(next) 185 } 186 mist.Binary(_) -> mist.continue(session) 187 mist.Closed | mist.Shutdown -> mist.stop() 188 mist.Custom(_) -> mist.continue(session) 189 } 190 }, 191 on_close: fn(_state) { Nil }, 192 ) 193} 194 195// ── Main ───────────────────────────────────────────────────────────────────── 196 197pub fn main() -> Nil { 198 let assert Ok(_) = 199 mist.new(handle_request) 200 |> mist.port(port) 201 |> mist.start 202 203 io.println("Star (Lightspeed) at http://localhost:" <> int.to_string(port)) 204 io.println(" GET / live view") 205 io.println(" WS /live lightspeed protocol") 206 io.println(" GET /health ok") 207 io.println(" GET /assets/* css + client") 208 process.sleep_forever() 209} 210 211// ── Assets ─────────────────────────────────────────────────────────────────── 212 213fn app_css() -> String { 214 ":root { 215 color-scheme: light; 216 --bg: #ffffff; 217 --bg-alt: #f6f5f4; 218 --ink: rgba(0, 0, 0, 0.95); 219 --ink-secondary: #615d59; 220 --ink-tertiary: #a39e98; 221 --accent: #0075de; 222 --accent-active: #005bab; 223 --border: rgba(0, 0, 0, 0.1); 224 --shadow-card: 225 rgba(0, 0, 0, 0.04) 0px 4px 18px, 226 rgba(0, 0, 0, 0.027) 0px 2.025px 7.85px, 227 rgba(0, 0, 0, 0.02) 0px 0.8px 2.93px, 228 rgba(0, 0, 0, 0.01) 0px 0.175px 1.04px; 229 --radius: 12px; 230 --radius-sm: 4px; 231} 232* { box-sizing: border-box; } 233body { 234 margin: 0; 235 min-height: 100vh; 236 font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; 237 -webkit-font-smoothing: antialiased; 238 color: var(--ink); 239 background: var(--bg); 240 line-height: 1.5; 241} 242.layout { 243 width: min(720px, calc(100vw - 32px)); 244 margin: 0 auto; 245 padding: 56px 0 80px; 246} 247header { margin-bottom: 12px; } 248h1 { 249 margin: 0; 250 font-size: 1.625rem; 251 font-weight: 700; 252 letter-spacing: -0.025em; 253} 254.tagline { 255 margin: 6px 0 36px; 256 color: var(--ink-secondary); 257 font-size: 0.9375rem; 258} 259.grid { 260 display: grid; 261 grid-template-columns: repeat(2, minmax(0, 1fr)); 262 gap: 12px; 263 margin-bottom: 12px; 264} 265.card { 266 padding: 16px; 267 background: var(--bg); 268 border: 1px solid var(--border); 269 border-radius: var(--radius); 270 box-shadow: var(--shadow-card); 271} 272.card-label { 273 display: block; 274 margin-bottom: 12px; 275 font-size: 0.75rem; 276 font-weight: 600; 277 color: var(--ink-tertiary); 278 text-transform: uppercase; 279 letter-spacing: 0.08em; 280} 281.card-foot { 282 display: block; 283 margin-top: 12px; 284 font-size: 0.875rem; 285 color: var(--ink-secondary); 286} 287.card-foot strong { color: var(--ink); font-weight: 600; } 288.counter-display { 289 text-align: center; 290 padding: 16px 0 4px; 291 font-size: 3rem; 292 font-weight: 700; 293 line-height: 1; 294 font-variant-numeric: tabular-nums; 295 letter-spacing: -0.03em; 296} 297.counter-controls { display: flex; gap: 6px; } 298.counter-controls button { flex: 1; text-align: center; font-size: 1rem; font-weight: 500; padding: 8px; } 299.panel { 300 margin-top: 10px; 301 padding: 12px; 302 border-radius: var(--radius-sm); 303 background: var(--bg-alt); 304 border: 1px solid var(--border); 305} 306.panel-text { margin: 0; font-size: 0.875rem; line-height: 1.6; color: var(--ink-secondary); } 307.btn { 308 display: inline-flex; align-items: center; justify-content: center; 309 width: 100%; border: 1px solid var(--border); border-radius: var(--radius-sm); 310 padding: 8px 14px; background: var(--bg); color: var(--ink); 311 font: 500 0.875rem/1 'Inter', system-ui, sans-serif; cursor: pointer; 312} 313.btn:hover { background: var(--bg-alt); } 314.btn:active { transform: scale(0.97); } 315.btn-primary { 316 display: inline-flex; align-items: center; justify-content: center; 317 width: 100%; border: 0; border-radius: var(--radius-sm); 318 padding: 10px 20px; background: var(--accent); color: #fff; 319 font: 600 0.9375rem/1 'Inter', system-ui, sans-serif; cursor: pointer; 320 margin-top: 10px; 321} 322.btn-primary:hover { background: var(--accent-active); } 323label { display: block; margin-bottom: 6px; font-size: 0.875rem; font-weight: 500; } 324input, textarea { 325 display: block; width: 100%; padding: 10px 12px; 326 border: 1px solid #ddd; border-radius: var(--radius-sm); 327 background: var(--bg); color: var(--ink); 328 font: 400 0.9375rem/1.5 'Inter', system-ui, sans-serif; outline: none; 329} 330input:focus, textarea:focus { 331 border-color: var(--accent); 332 box-shadow: 0 0 0 3px rgba(0, 117, 222, 0.12); 333} 334textarea { resize: vertical; } 335.muted { color: var(--ink-tertiary); font-size: 0.9375rem; margin: 0; } 336.flash { 337 margin: 0 0 10px; 338 padding: 8px 10px; 339 background: rgba(0, 117, 222, 0.08); 340 border-radius: var(--radius-sm); 341 font-size: 0.875rem; 342 color: var(--accent-active); 343} 344.note-form { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; } 345.notes-list { display: flex; flex-direction: column; gap: 6px; } 346.note-item { 347 padding: 12px 14px; background: var(--bg-alt); 348 border: 1px solid var(--border); border-radius: 8px; 349} 350.note-head { 351 display: flex; align-items: center; justify-content: space-between; 352 gap: 8px; margin-bottom: 2px; 353} 354.note-item strong { font-size: 0.9375rem; font-weight: 600; } 355.note-item p { margin: 0; color: var(--ink-secondary); font-size: 0.875rem; line-height: 1.5; } 356.note-delete { 357 width: auto; padding: 5px 12px; font-size: 0.8125rem; font-weight: 500; 358 border-radius: 4px; cursor: pointer; background: transparent; 359 color: #dd5b00; border: 1px solid rgba(221, 91, 0, 0.2); 360} 361.note-delete:hover { background: rgba(221, 91, 0, 0.06); } 362.links { 363 margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--border); 364 font-size: 0.8125rem; color: var(--ink-tertiary); 365} 366#app.ls-connected { outline: 2px solid rgba(0, 117, 222, 0.15); outline-offset: 4px; } 367@media (max-width: 640px) { 368 .layout { padding-top: 24px; } 369 .grid { grid-template-columns: 1fr; } 370} 371" 372} 373 374/// Minimal Lightspeed browser runtime: connect, push events, apply Replace diffs. 375fn lightspeed_client() -> String { 376 "const ROOT = document.getElementById('app'); 377if (!ROOT) throw new Error('Lightspeed: #app missing'); 378 379const wsPath = ROOT.dataset.lsWs || '/live'; 380const proto = location.protocol === 'https:' ? 'wss' : 'ws'; 381const url = proto + '://' + location.host + wsPath; 382 383let socket; 384let refSeq = 1; 385 386function escapeField(value) { 387 return String(value).replace(/\\\\/g, '\\\\\\\\').replace(/\\|/g, '\\\\|'); 388} 389 390function encodeEvent(name, payload) { 391 const ref = String(refSeq++); 392 return ['event', ref, name, payload || ''].map(escapeField).join('|'); 393} 394 395function splitFields(payload) { 396 const fields = []; 397 let cur = ''; 398 let esc = false; 399 for (const ch of payload) { 400 if (esc) { cur += ch; esc = false; continue; } 401 if (ch === '\\\\') { esc = true; continue; } 402 if (ch === '|') { fields.push(cur); cur = ''; continue; } 403 cur += ch; 404 } 405 fields.push(cur); 406 return fields; 407} 408 409function applyReplace(target, html) { 410 const el = document.querySelector(target); 411 if (!el) return; 412 if (target === '#app' || el.id === 'app') { 413 el.innerHTML = html; 414 } else { 415 el.outerHTML = html; 416 } 417 bindClicks(ROOT); 418} 419 420function applyPatchStream(encoded) { 421 const fields = splitFields(encoded); 422 if (fields[0] !== 'ps') return; 423 const version = Number(fields[1]); 424 if (version !== 1) return; 425 const dictLen = Number(fields[2]); 426 const dict = fields.slice(3, 3 + dictLen); 427 let i = 3 + dictLen; 428 const opCount = Number(fields[i++]); 429 for (let n = 0; n < opCount; n++) { 430 const op = fields[i++]; 431 if (!op) break; 432 // tokens joined with commas inside the op field: r,targetIdx,htmlIdx 433 const tokens = op.split(','); 434 if (tokens[0] === 'r') { 435 const target = dict[Number(tokens[1])]; 436 const html = dict[Number(tokens[2])]; 437 applyReplace(target, html); 438 } 439 } 440} 441 442function onFrame(text) { 443 const fields = splitFields(text); 444 const tag = fields[0]; 445 if (tag === 'hello') { 446 ROOT.classList.add('ls-connected'); 447 return; 448 } 449 if (tag === 'diff') { 450 applyPatchStream(fields[2] || ''); 451 const pref = fields[1] || ''; 452 if (socket && socket.readyState === 1 && pref) { 453 socket.send(['ack', pref].map(escapeField).join('|')); 454 } 455 } 456} 457 458function collectFormPayload(extra) { 459 const params = new URLSearchParams(); 460 ROOT.querySelectorAll('input[name], textarea[name]').forEach((el) => { 461 params.set(el.name, el.value || ''); 462 }); 463 if (extra) { 464 Object.entries(extra).forEach(([k, v]) => params.set(k, v)); 465 } 466 return params.toString(); 467} 468 469function pushEvent(name, extra) { 470 if (!socket || socket.readyState !== 1) return; 471 let payload = ''; 472 if (name === 'greet' || name === 'save_note') { 473 payload = collectFormPayload(extra); 474 } else if (name === 'delete_note' && extra && extra.id != null) { 475 payload = 'id=' + encodeURIComponent(String(extra.id)); 476 } 477 socket.send(encodeEvent(name, payload)); 478} 479 480function bindClicks(root) { 481 root.querySelectorAll('[data-ls-click]').forEach((el) => { 482 if (el.__lsBound) return; 483 el.__lsBound = true; 484 el.addEventListener('click', (ev) => { 485 ev.preventDefault(); 486 const name = el.getAttribute('data-ls-click'); 487 const value = el.getAttribute('data-ls-value'); 488 const extra = value != null ? { id: value } : null; 489 pushEvent(name, extra); 490 }); 491 }); 492} 493 494function connect() { 495 socket = new WebSocket(url); 496 socket.addEventListener('message', (ev) => onFrame(String(ev.data))); 497 socket.addEventListener('close', () => { 498 ROOT.classList.remove('ls-connected'); 499 setTimeout(connect, 1000); 500 }); 501 socket.addEventListener('error', () => { try { socket.close(); } catch (_) {} }); 502} 503 504bindClicks(ROOT); 505connect(); 506window.__lightspeed = { pushEvent }; 507" 508}