Public: anyone can read this; the crew can change it.

The page is a timeline: server-rendered, filtered by link, live as it happens #34

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/relay:scout/9-the-page-is-a-timeline-server-rendered-f → main 1 commit fork

  • Merged. scout/9-the-page-is-a-timeline-server-rendered-f was merged into main as 72abddf.

Review

Sign in to review this change.
6 files changed +442 −65
src/http.ts +15
@@ -175,3 +175,18 @@ export function staticFile(name: string, versioned: boolean): Response {
175175 },
176176 });
177177 }
178
179 // Docs read straight from the repository, for a page to link to.
180 const DOCS = join(import.meta.dir, "..", "docs");
181 export function docFile(name: string): Response {
182 const path = join(DOCS, name);
183 if (name.includes("/") || name.includes("..") || !existsSync(path))
184 return text("not found", 404);
185 return new Response(Bun.file(path), {
186 headers: {
187 "content-type": "text/markdown; charset=utf-8",
188 "cache-control": "no-cache",
189 ...SECURITY,
190 },
191 });
192 }
src/page.ts +138 −17
@@ -5,7 +5,15 @@
55 */
66
77 import type { Event } from "./actions.ts";
8 import { commit, description, env, ssh, title, where } from "./env.ts";
8 import {
9 commit,
10 description,
11 env,
12 publicOrigin,
13 ssh,
14 title,
15 where,
16 } from "./env.ts";
917 import { asset, escape } from "./http.ts";
1018 import type { Who } from "./who.ts";
1119
@@ -47,27 +55,140 @@ export function layout(body: string, who: Who): string {
4755 `;
4856 }
4957
50 export function home(input: { who: Who; events: readonly Event[] }): string {
51 const { who, events } = input;
52 const list =
53 events.length === 0
54 ? `<p class="empty">No events yet. Events posted here stay in ${escape(where)} data.</p>`
55 : `<ul class="list">${events.map(item).join("")}</ul>`;
58 export function home(input: {
59 who: Who;
60 events: readonly Event[];
61 source: string | null;
62 kind: string | null;
63 error?: string;
64 }): string {
65 const { who, events, source, kind, error } = input;
66 const filtered = source !== null || kind !== null;
67 const body =
68 events.length === 0 ? empty(filtered) : days(events, source, kind);
5669 return layout(
57 `<section id="events" data-live="event.posted">${list}</section>`,
70 `<section id="events" data-live="event.posted">
71 ${toolbar(source, kind, error)}
72 ${body}
73 </section>`,
5874 who,
5975 );
6076 }
6177
62 const item = (event: Event): string => `<li class="event">
63 <div>
64 <p><b>${escape(event.subject)}</b> — ${escape(event.summary)}</p>
65 <small class="muted">${escape(event.source)} · ${escape(event.kind)} · <time datetime="${escape(event.occurred_at)}">${ago(event.occurred_at)}</time>${
66 event.link === null
67 ? ""
68 : ` · <a href="${escape(event.link)}" rel="noopener noreferrer">link</a>`
69 }</small>
70 </div>
78 /** A link that carries the filter forward: dropping a field clears it. */
79 const filterHref = (source: string | null, kind: string | null): string => {
80 const params = new URLSearchParams();
81 if (source !== null) params.set("source", source);
82 if (kind !== null) params.set("kind", kind);
83 const qs = params.toString();
84 return qs === "" ? "/" : `/?${qs}`;
85 };
86
87 const toggle = (current: string | null, value: string): string | null =>
88 current === value ? null : value;
89
90 /** The action's message when a filter is invalid, and the active filter as clearable chips. */
91 const toolbar = (
92 source: string | null,
93 kind: string | null,
94 error: string | undefined,
95 ): string => {
96 const parts: string[] = [];
97 if (error !== undefined)
98 parts.push(`<p class="notice" role="alert">${escape(error)}</p>`);
99 const chips: string[] = [];
100 if (source !== null)
101 chips.push(
102 `<a class="badge badge-active" href="${escape(filterHref(null, kind))}">${escape(source)} <span aria-hidden="true">✕</span></a>`,
103 );
104 if (kind !== null)
105 chips.push(
106 `<a class="kind kind-active" href="${escape(filterHref(source, null))}">${escape(kind)} <span aria-hidden="true">✕</span></a>`,
107 );
108 if (chips.length > 0)
109 parts.push(
110 `<p class="filters">Filtered by ${chips.join(" ")}${
111 source !== null && kind !== null
112 ? ` <a class="clear" href="/">clear all</a>`
113 : ""
114 }</p>`,
115 );
116 return parts.join("");
117 };
118
119 const empty = (filtered: boolean): string =>
120 filtered
121 ? `<p class="empty">No events match this filter. <a href="/">Clear it</a> to see everything.</p>`
122 : `<div class="empty">
123 <p>No events yet. Post one from another app:</p>
124 <pre class="mono">curl -X POST ${escape(publicOrigin)}/api/events \\
125 -H "content-type: application/json" \\
126 -H "x-relay-app: $RELAY_APP" \\
127 -H "x-relay-timestamp: $TIMESTAMP" \\
128 -H "x-relay-nonce: $NONCE" \\
129 -H "x-relay-signature: $SIGNATURE" \\
130 --data-binary '{"kind":"order.placed","subject":"Order #42","summary":"A new order came in."}'</pre>
131 <p>See <a href="/docs/adopting.md">docs/adopting.md</a> for how to get a key and sign a request.</p>
132 </div>`;
133
134 /** Newest first, grouped under a heading for the day it happened. */
135 const days = (
136 events: readonly Event[],
137 source: string | null,
138 kind: string | null,
139 ): string => {
140 const out: string[] = [];
141 let current: string | undefined;
142 let items: Event[] = [];
143 const flush = (): void => {
144 if (current === undefined) return;
145 out.push(
146 `<h2 class="day">${escape(dayLabel(current))}</h2><ul class="list" data-day="${escape(current)}">${items.map((e) => item(e, source, kind)).join("")}</ul>`,
147 );
148 };
149 for (const event of events) {
150 const key = dayKey(event.occurred_at);
151 if (key !== current) {
152 flush();
153 current = key;
154 items = [];
155 }
156 items.push(event);
157 }
158 flush();
159 return out.join("");
160 };
161
162 const dayKey = (iso: string): string => iso.slice(0, 10);
163
164 const dayLabel = (key: string): string => {
165 const today = new Date().toISOString().slice(0, 10);
166 const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
167 if (key === today) return "Today";
168 if (key === yesterday) return "Yesterday";
169 return new Date(`${key}T00:00:00Z`).toLocaleDateString("en", {
170 month: "long",
171 day: "numeric",
172 year: "numeric",
173 timeZone: "UTC",
174 });
175 };
176
177 const item = (
178 event: Event,
179 source: string | null,
180 kind: string | null,
181 ): string => `<li class="event" data-id="${event.id}">
182 <p class="body"><b>${escape(event.subject)}</b> — ${escape(event.summary)}${
183 event.link === null
184 ? ""
185 : ` · <a href="${escape(event.link)}" rel="noopener noreferrer">link</a>`
186 }</p>
187 <p class="meta">
188 <a class="badge${source === event.source ? " badge-active" : ""}" href="${escape(filterHref(toggle(source, event.source), kind))}">${escape(event.source)}</a>
189 <a class="kind${kind === event.kind ? " kind-active" : ""}" href="${escape(filterHref(source, toggle(kind, event.kind)))}">${escape(event.kind)}</a>
190 <time datetime="${escape(event.occurred_at)}">${ago(event.occurred_at)}</time>
191 </p>
71192 </li>`;
72193
73194 const ago = (iso: string): string => {
src/routes.ts +19 −2
@@ -13,6 +13,7 @@ import {
1313 type Result,
1414 } from "./actions.ts";
1515 import {
16 docFile,
1617 fields,
1718 html,
1819 json,
@@ -61,8 +62,21 @@ export const routes: readonly Route[] = [
6162 method: "GET",
6263 path: "/",
6364 handle: (c) => {
64 const listed = listEvents(c.db, c.who);
65 return html(home({ who: c.who, events: listed.ok ? listed.value : [] }));
65 const source = c.url.searchParams.get("source");
66 const kind = c.url.searchParams.get("kind");
67 const listed = listEvents(c.db, c.who, {
68 source: source ?? undefined,
69 kind: kind ?? undefined,
70 });
71 return html(
72 home({
73 who: c.who,
74 events: listed.ok ? listed.value : [],
75 source,
76 kind,
77 error: listed.ok ? undefined : listed.message,
78 }),
79 );
6680 },
6781 },
6882
@@ -131,6 +145,9 @@ export const routes: readonly Route[] = [
131145 path: "/static/:file",
132146 handle: (c) => staticFile(c.params.file ?? "", c.url.searchParams.has("v")),
133147 },
148
149 // Files under ./docs, read straight from the repository.
150 { method: "GET", path: "/docs/:file", handle: (c) => docFile(c.params.file ?? "") },
134151 ];
135152
136153 /** The route for a method and path, with what its `:names` matched. */
src/server.test.ts +59
@@ -95,6 +95,65 @@ test("the JSON API: only an admin (or a verified app) posts, as a named source",
9595 expect(invalid.status).toBe(422);
9696 });
9797
98 test("the timeline is server-rendered, escaped, and its filter round-trips", async () => {
99 await api(
100 "/api/events",
101 {
102 method: "POST",
103 ...jsonBody({
104 kind: "order.placed",
105 subject: "Order #9001",
106 summary: "<script>alert(1)</script>",
107 source: "timeline",
108 }),
109 },
110 root,
111 );
112 const page = await (await fetch(base + "/")).text();
113 // The newest event is in the first paint — no JavaScript involved, this
114 // is a plain fetch of the HTML.
115 expect(page).toContain("Order #9001");
116 // A poster-controlled field never becomes markup.
117 expect(page).not.toContain("<script>alert(1)</script>");
118 expect(page).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
119 // Nothing inline was added to make any of this work.
120 expect(/<script(?![^>]*\bsrc=)/.test(page)).toBe(false);
121
122 const filtered = await (await fetch(base + "/?source=timeline")).text();
123 expect(filtered).toContain("Order #9001");
124 // The filter carries forward into the other chips, so it survives a click.
125 expect(filtered).toContain('href="/?source=timeline&amp;kind=order.placed"');
126 // The active chip clears the filter it names, back to the same address.
127 expect(filtered).toContain('class="badge badge-active" href="/"');
128 const reloaded = await (await fetch(base + "/?source=timeline")).text();
129 expect(reloaded).toBe(filtered);
130
131 const invalid = await fetch(base + "/?source=Not%20Valid!");
132 expect(invalid.status).toBe(200);
133 expect(await invalid.text()).toContain("source is invalid");
134
135 expect(
136 (await fetch(base + "/")).headers.get("content-security-policy"),
137 ).toContain("default-src 'self'");
138 });
139
140 test("an empty timeline explains how to post, with no dead ends", async () => {
141 const fresh = serve({
142 port: 0,
143 dataDir: mkdtempSync(join(tmpdir(), "app-")),
144 identityKey: KEY,
145 });
146 try {
147 const freshBase = `http://${fresh.hostname}:${fresh.port}`;
148 const page = await (await fetch(freshBase + "/")).text();
149 expect(page).toContain("curl -X POST");
150 expect(page).toContain("/docs/adopting.md");
151 expect((await fetch(freshBase + "/docs/adopting.md")).status).toBe(200);
152 } finally {
153 fresh.stop(true);
154 }
155 });
156
98157 test("kind and source filters work over the API", async () => {
99158 await api(
100159 "/api/events",
static/app.css +88 −6
@@ -109,16 +109,25 @@ code,
109109 align-items: center;
110110 gap: var(--s2);
111111 }
112 .day {
113 margin: var(--s5) 0 var(--s2);
114 font-size: var(--text-sm);
115 font-weight: 700;
116 color: var(--muted);
117 text-transform: uppercase;
118 letter-spacing: 0.04em;
119 }
120 .day:first-child {
121 margin-top: 0;
122 }
112123 .list {
113124 list-style: none;
114125 margin: 0;
115126 padding: 0;
116127 }
117128 .event {
118 display: flex;
119 justify-content: space-between;
120 align-items: flex-start;
121 gap: var(--s3);
129 display: grid;
130 gap: var(--s1);
122131 padding: var(--s3) 0;
123132 border-top: 1px solid var(--line);
124133 }
@@ -126,18 +135,59 @@ code,
126135 border-top: 0;
127136 }
128137 .event p {
138 margin: 0;
129139 overflow-wrap: anywhere;
130140 }
131 .event small {
141 .event .meta {
142 display: flex;
143 flex-wrap: wrap;
144 align-items: center;
145 gap: var(--s1) var(--s2);
132146 font-size: var(--text-sm);
147 color: var(--muted);
148 }
149 .event time {
150 white-space: nowrap;
133151 }
134152 .empty {
135 padding: var(--s6) var(--s4);
153 padding: var(--s5) var(--s4);
136154 text-align: center;
137155 color: var(--muted);
138156 border: 1px dashed var(--line);
139157 border-radius: var(--radius);
140158 }
159 .empty pre {
160 margin: var(--s3) 0;
161 padding: var(--s3);
162 overflow-x: auto;
163 text-align: left;
164 background: var(--surface);
165 border: 1px solid var(--line);
166 border-radius: var(--radius);
167 }
168 .notice {
169 padding: var(--s3);
170 color: var(--danger);
171 background: var(--surface);
172 border: 1px solid currentColor;
173 border-radius: var(--radius);
174 margin-bottom: var(--s4);
175 }
176 .filters {
177 display: flex;
178 flex-wrap: wrap;
179 align-items: center;
180 gap: var(--s2);
181 margin-bottom: var(--s4);
182 color: var(--muted);
183 }
184 /* Standalone, away from a dense row: full tap size. */
185 .filters .badge,
186 .filters .kind,
187 .filters .clear {
188 min-height: var(--tap);
189 padding: 0 var(--s4);
190 }
141191 footer {
142192 margin-top: var(--s6);
143193 font-size: var(--text-sm);
@@ -190,3 +240,35 @@ footer code {
190240 color: var(--accent);
191241 border-color: currentColor;
192242 }
243
244 /* Filter chips: a source is a badge, a kind is plainer text — either
245 clicked sets that filter, and clicking the one already active clears it. */
246 .badge,
247 .kind,
248 .clear {
249 display: inline-flex;
250 align-items: center;
251 min-height: 2rem;
252 padding: 0.1rem 0.6rem;
253 font-size: var(--text-sm);
254 text-decoration: none;
255 border-radius: 999px;
256 }
257 .badge {
258 font-weight: 600;
259 color: var(--muted);
260 border: 1px solid var(--line);
261 }
262 .kind,
263 .clear {
264 color: var(--muted);
265 text-decoration: underline;
266 text-decoration-thickness: 1px;
267 }
268 .badge-active,
269 .kind-active {
270 color: var(--accent-fg);
271 background: var(--accent);
272 border-color: var(--accent);
273 text-decoration: none;
274 }
static/live.js +123 −40
@@ -1,8 +1,8 @@
11 // The page stays current without a reload. /events is a stream of what
2 // changed; each event becomes `live` and `live:<type>` on `document`, and
3 // any element with data-live="<types>" is refetched and swapped when one of
4 // its types arrives — server-rendered, so the page never renders twice.
5 // Without this file the page is the same page, one reload behind.
2 // changed; each event becomes `live` and `live:<type>` on `document`. The
3 // timeline (`data-live="event.posted"`) prepends a row built straight from
4 // the pushed data with textContent, never innerHTML — that data came from
5 // another app on the forge, and is never trusted as markup.
66
77 let since = "";
88 let tries = 0;
@@ -11,7 +11,6 @@ let source;
1111 function connect() {
1212 source = new EventSource(since ? `/events?since=${since}` : "/events");
1313 source.onopen = () => {
14 if (tries > 0) refresh(); // Reconnected: whatever was missed, catch up.
1514 tries = 0;
1615 };
1716 source.onmessage = (message) => {
@@ -21,7 +20,8 @@ function connect() {
2120 document.dispatchEvent(new CustomEvent(`live:${type}`, { detail: data }));
2221 };
2322 // The browser retries a dropped connection itself; a refused one it gives
24 // up on, and then we try again, later each time.
23 // up on, and then we try again, later each time. Reconnecting with
24 // ?since= replays whatever was missed, as ordinary messages.
2525 source.onerror = () => {
2626 if (source.readyState !== EventSource.CLOSED) return;
2727 source.close();
@@ -29,40 +29,123 @@ function connect() {
2929 };
3030 }
3131
32 let refreshing = false;
33 let again = false;
34 async function refresh(types) {
35 const regions = [...document.querySelectorAll("[data-live]")].filter(
36 (el) =>
37 types === undefined ||
38 el.dataset.live.split(" ").some((t) => types.includes(t)),
39 );
40 if (regions.length === 0) return;
41 if (refreshing) {
42 again = true;
43 return;
44 }
45 refreshing = true;
46 try {
47 const response = await fetch(location.href, {
48 headers: { accept: "text/html" },
49 });
50 const fresh = new DOMParser().parseFromString(
51 await response.text(),
52 "text/html",
53 );
54 for (const el of regions) {
55 const next = fresh.getElementById(el.id);
56 if (next) el.replaceWith(next);
57 }
58 } finally {
59 refreshing = false;
60 if (again) {
61 again = false;
62 refresh();
63 }
64 }
32 // Mirrors src/actions.ts: kind matches exactly, or as a dotted prefix.
33 function matchesFilter(event) {
34 const params = new URLSearchParams(location.search);
35 const source = params.get("source");
36 const kind = params.get("kind");
37 if (source !== null && event.source !== source) return false;
38 if (kind !== null && event.kind !== kind && !event.kind.startsWith(kind + "."))
39 return false;
40 return true;
6541 }
6642
67 document.addEventListener("live", (event) => refresh([event.detail.type]));
43 // Mirrors src/page.ts: relative time for a row just built client-side.
44 function ago(iso) {
45 const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
46 if (s < 60) return "just now";
47 if (s < 3600) return `${Math.floor(s / 60)}m ago`;
48 if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
49 return new Date(iso).toLocaleDateString("en", { month: "short", day: "numeric" });
50 }
51
52 function dayKey(iso) {
53 return iso.slice(0, 10);
54 }
55
56 function dayLabel(key) {
57 const today = new Date().toISOString().slice(0, 10);
58 const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
59 if (key === today) return "Today";
60 if (key === yesterday) return "Yesterday";
61 return new Date(`${key}T00:00:00Z`).toLocaleDateString("en", {
62 month: "long",
63 day: "numeric",
64 year: "numeric",
65 timeZone: "UTC",
66 });
67 }
68
69 function link(href, text) {
70 const a = document.createElement("a");
71 a.href = href;
72 a.textContent = text;
73 return a;
74 }
75
76 function buildRow(event) {
77 const li = document.createElement("li");
78 li.className = "event";
79 li.dataset.id = String(event.id);
80
81 const body = document.createElement("p");
82 body.className = "body";
83 const b = document.createElement("b");
84 b.textContent = event.subject;
85 body.append(b, ` — ${event.summary}`);
86 if (event.link !== null) {
87 try {
88 const url = new URL(event.link);
89 if (url.protocol === "http:" || url.protocol === "https:") {
90 body.append(" · ");
91 const a = link(event.link, "link");
92 a.rel = "noopener noreferrer";
93 body.append(a);
94 }
95 } catch {
96 // Not a URL relay would have accepted; leave it off the row.
97 }
98 }
99
100 const meta = document.createElement("p");
101 meta.className = "meta";
102 const params = new URLSearchParams(location.search);
103 const sourceActive = params.get("source") === event.source;
104 const sourceParams = new URLSearchParams(params);
105 if (sourceActive) sourceParams.delete("source");
106 else sourceParams.set("source", event.source);
107 const badge = link(sourceParams.size ? `/?${sourceParams}` : "/", event.source);
108 badge.className = sourceActive ? "badge badge-active" : "badge";
109 const kindActive = params.get("kind") === event.kind;
110 const kindParams = new URLSearchParams(params);
111 if (kindActive) kindParams.delete("kind");
112 else kindParams.set("kind", event.kind);
113 const kind = link(kindParams.size ? `/?${kindParams}` : "/", event.kind);
114 kind.className = kindActive ? "kind kind-active" : "kind";
115 const time = document.createElement("time");
116 time.dateTime = event.occurred_at;
117 time.textContent = ago(event.occurred_at);
118 meta.append(badge, " ", kind, " ", time);
119
120 li.append(body, meta);
121 return li;
122 }
123
124 function addEvent(event) {
125 const section = document.querySelector('[data-live~="event.posted"]');
126 if (section === null || !matchesFilter(event)) return;
127 if (section.querySelector(`[data-id="${CSS.escape(String(event.id))}"]`))
128 return; // Already shown — a reconnect replayed it, or two tabs raced.
129
130 const key = dayKey(event.occurred_at);
131 let list = section.querySelector(`.list[data-day="${CSS.escape(key)}"]`);
132 if (list === null) {
133 const heading = document.createElement("h2");
134 heading.className = "day";
135 heading.textContent = dayLabel(key);
136 list = document.createElement("ul");
137 list.className = "list";
138 list.dataset.day = key;
139 const firstDay = section.querySelector(".day");
140 section.querySelector(".empty")?.remove();
141 if (firstDay === null) section.append(heading, list);
142 else {
143 section.insertBefore(heading, firstDay);
144 section.insertBefore(list, firstDay);
145 }
146 }
147 list.prepend(buildRow(event));
148 }
149
150 document.addEventListener("live:event.posted", (event) => addEvent(event.detail));
68151 connect();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs