Native Lightspeed LiveView app on Mist (Gleam)
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}