Compare commits

..

No commits in common. "465cc747961372d7001b14f30cd5e0d5fc681809" and "c175a610da5ec5fda6f27430f834866df994ea2e" have entirely different histories.

14 changed files with 150 additions and 1738 deletions

1
.gitignore vendored
View file

@ -1,5 +1,4 @@
node_modules
.venv
.DS_Store
dbs
.env

View file

@ -1,57 +0,0 @@
<article data-bliss-artifact-editor
data-kind="<%= it.kind %>" data-id="<%= it.artifact.id %>"
data-structure-id="<%= it.artifact.structure_id %>" data-method="<%= it.artifact.verb || '' %>"
class="border-2 border-black bg-white text-black shadow-[4px_4px_0_#000]">
<header class="flex items-center gap-2 border-b-4 border-double border-black p-3">
<strong class="min-w-0 flex-1 truncate"><% if (it.kind === 'route') { %><%= it.artifact.verb %> <%= it.artifact.path %><% } else { %><%= it.artifact.name %><% } %></strong>
<span class="text-xs uppercase"><%= it.kind %></span><span data-status class="text-xs"></span>
</header>
<div class="p-3">
<% if (it.kind === 'route' && it.artifact.verb !== 'GET') { %>
<p class="mb-2 border border-amber-700 bg-amber-100 p-2 text-sm">Saving does not replay this <%= it.artifact.verb %> route.</p>
<% } %>
<label class="block text-xs font-bold uppercase tracking-widest"><%= it.kind === 'route' ? 'Handler' : 'Template' %></label>
<div data-editor="<%= it.kind === 'route' ? 'handler' : 'content' %>" data-mode="<%= it.kind === 'route' ? 'javascript' : 'html' %>" class="h-[min(55vh,34rem)] w-full border-2 border-black"><%= it.kind === 'route' ? it.artifact.handler : it.artifact.content %></div>
<% if (it.kind === 'template') { %>
<details class="mt-3" open>
<summary class="cursor-pointer text-xs font-bold uppercase tracking-widest">Rendering test</summary>
<div data-editor="test_object" data-mode="javascript" class="mt-1 h-32 w-full border-2 border-black"><%= it.artifact.test_object || '' %></div>
<iframe data-template-test title="Template rendering test" src="/workshop/<%= it.artifact.structure_id %>/template/<%= it.artifact.id %>/preview" class="mt-2 h-64 w-full border-2 border-black"></iframe>
</details>
<% } %>
<button data-save type="button" class="mt-3 border-2 border-black bg-white px-3 py-2 font-bold shadow-[3px_3px_0_#000]">Save</button>
</div>
</article>
<script>
(function () {
const card = document.currentScript.previousElementSibling;
if (!card || card.dataset.ready) return;
card.dataset.ready = "true";
const editors = Object.fromEntries([...card.querySelectorAll("[data-editor]")].map((node) => {
const editor = ace.edit(node);
editor.setTheme("ace/theme/monokai");
editor.session.setMode(`ace/mode/${node.dataset.mode}`);
return [node.dataset.editor, editor];
}));
card.querySelector("[data-save]").onclick = async () => {
const status = card.querySelector("[data-status]");
const { kind, id, structureId } = card.dataset;
status.textContent = "Saving…";
const body = kind === "route"
? { handler: editors.handler.getValue() }
: { content: editors.content.getValue(), test_object: editors.test_object.getValue() };
const response = await fetch(`/workshop/${structureId}/${kind}/${id}`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
});
if (!response.ok) return void (status.textContent = "Save failed");
status.textContent = "Saved";
if (kind === "template") {
const frame = card.querySelector("[data-template-test]");
frame.src = `${frame.src.split("?")[0]}?t=${Date.now()}`;
}
document.dispatchEvent(new CustomEvent("bliss:source-saved", {
detail: { kind, id, method: kind === "route" ? card.dataset.verb : undefined },
}));
};
})();
</script>

View file

@ -1,3 +0,0 @@
function handler(req, res) {
res.render("inspector/slideout", {});
}

View file

@ -1,80 +0,0 @@
#!/usr/bin/env node
// Install/update the live editor using Bliss's public CLI workflow. This file
// intentionally does not open the Bliss database directly.
const { spawnSync } = require("node:child_process");
const path = require("node:path");
const repo = path.resolve(__dirname, "../..");
const cli = path.join(repo, "bliss-cli/bliss");
function bliss(...args) {
const result = spawnSync(process.execPath, [cli, ...args], {
cwd: repo,
env: process.env,
encoding: "utf8",
});
if (result.status !== 0) throw new Error(result.stderr || result.stdout);
return result.stdout.trim();
}
function json(...args) {
return JSON.parse(bliss(...args));
}
const structures = json("structures");
let structure = structures.find((item) => item.name === "__bliss_live_editor");
if (!structure) {
const created = json("create-structure", "__bliss_live_editor");
structure = { id: created.id };
}
const structureId = String(structure.id);
bliss("update-settings", structureId, "--route-prefix", "/_bliss");
let state = json("structure", structureId);
if (!state.dbs.some((db) => String(db.id) === "0" && db.alias === "bliss")) {
bliss("attach-db", structureId, "0", "bliss");
}
const templates = [
["inspector/slideout", "slideout.eta"],
["inspector/route_editor", "route-editor.eta"],
["inspector/template_editor", "template-editor.eta"],
["inspector/artifact_editor", "artifact-editor.eta"],
];
for (const [name, filename] of templates) {
let template = state.templates.find((item) => item.name === name);
if (!template) {
template = json("create-template", structureId, name);
}
bliss(
"update-template",
structureId,
String(template.id),
"--content-file",
path.join(__dirname, filename),
);
}
const routes = [
["/inspector", "inspector.js"],
["/editor/route/:id", "route-editor.js"],
["/editor/template/:id", "template-editor.js"],
];
for (const [routePath, filename] of routes) {
let route = state.routes.find(
(item) => item.verb === "GET" && item.path === routePath,
);
if (!route) {
route = json("create-route", structureId, "GET", routePath);
}
bliss(
"update-route",
structureId,
String(route.id),
"--handler-file",
path.join(__dirname, filename),
);
}
console.log(`live editor installed as Structure ${structureId}`);

View file

@ -1,28 +0,0 @@
<div data-bliss-ui>
<div class="mb-2 flex items-center justify-between border-b-4 border-double border-black pb-2"><strong><%= it.sourceRoute.verb %> <%= it.sourceRoute.path %></strong><span data-status class="text-xs"></span></div>
<% if (it.sourceRoute.verb !== "GET") { %>
<div class="mb-2 rounded border border-amber-600 bg-amber-950 p-2 text-amber-200">Rerunning this <%= it.sourceRoute.verb %> request can repeat writes or external side effects. Bliss will not rerun it automatically.</div>
<% } %>
<div data-source class="h-[55vh] w-full border-2 border-black"><%= it.sourceRoute.handler %></div>
<button data-save type="button" class="mt-2 border-2 border-black bg-white px-3 py-2 font-bold shadow-[3px_3px_0_#000] active:translate-x-0.5 active:translate-y-0.5 active:shadow-none">Save Route</button>
</div>
<script>
(function () {
const box = document.currentScript.previousElementSibling;
if (!box) return;
const editor = ace.edit(box.querySelector("[data-source]"));
editor.setTheme("ace/theme/monokai");
editor.session.setMode("ace/mode/javascript");
box.querySelector("[data-save]").onclick = async () => {
const status = box.querySelector("[data-status]");
status.textContent = "Saving…";
const response = await fetch("/workshop/<%= it.sourceRoute.structure_id %>/route/<%= it.sourceRoute.id %>", {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ handler: editor.getValue() })
});
if (!response.ok) return void (status.textContent = "Save failed");
status.textContent = "Saved";
document.dispatchEvent(new CustomEvent("bliss:source-saved", { detail: { kind: "route", id: "<%= it.sourceRoute.id %>", method: "<%= it.sourceRoute.verb %>" } }));
};
})();
</script>

View file

@ -1,7 +0,0 @@
function handler(req, res) {
const sql = require("db")("bliss").sql;
const kind = "route";
const artifact = sql.prepare("SELECT * FROM routes WHERE id = ?").get(req.params.id);
if (!artifact) return res.status(404).send("Route not found");
res.render("inspector/artifact_editor", { kind, artifact });
}

View file

@ -1,200 +0,0 @@
<aside id="bliss-live-editor" data-bliss-ui
class="fixed inset-y-0 left-0 z-[2147483646] w-[min(28rem,90vw)] overflow-auto border-r-4 border-black bg-white text-black shadow-[8px_0_0_#000]"
style="font-family:ui-sans-serif,system-ui">
<header class="sticky top-0 flex items-center justify-between border-b-4 border-double border-black bg-white p-3">
<div><div class="text-lg font-black tracking-tight">Live Page</div><div class="text-xs">Objects currently on this card</div></div>
<button type="button" data-bliss-close class="h-7 w-7 border-2 border-black bg-white text-lg font-black leading-none shadow-[2px_2px_0_#000] active:translate-x-0.5 active:translate-y-0.5 active:shadow-none">×</button>
</header>
<div class="grid min-h-[calc(100vh-61px)] grid-rows-[auto_1fr]">
<nav data-bliss-tree class="border-b-4 border-double border-black p-2"></nav>
<section data-bliss-editor class="min-h-0 p-3 text-sm">Select a route or template to edit it, or use the ✏️ 📄 📋 👯 buttons on the page.</section>
</div>
</aside>
<script>
(function () {
const root = document.getElementById("bliss-live-editor");
if (!root) return;
// Re-running this script (the slideout is remounted on every open) must not
// pile up document listeners or leave a stale overlay behind.
if (window.__blissLive) window.__blissLive();
const cleanups = [];
const on = (target, type, handler, opts) => {
target.addEventListener(type, handler, opts);
cleanups.push(() => target.removeEventListener(type, handler, opts));
};
window.__blissLive = () => { for (const c of cleanups.splice(0)) c(); window.__blissLive = null; };
const tree = root.querySelector("[data-bliss-tree]");
const editor = root.querySelector("[data-bliss-editor]");
root.querySelector("[data-bliss-close]").onclick = () =>
document.dispatchEvent(new CustomEvent("bliss:inspector-close"));
const highlight = (kind, id, method, state) =>
document.dispatchEvent(new CustomEvent("bliss:highlight", { detail: { kind, id, method, on: state } }));
function openEditor(kind, id) {
editor.textContent = "Loading…";
htmx.ajax("GET", `/_bliss/editor/${kind}/${id}`, { target: editor, swap: "innerHTML" });
}
// Clone runs through the workshop's own modal, dropped into a full-screen host
// we manage so it stacks above the slideout and closes on backdrop click.
function openModal(url) {
const host = document.createElement("div");
host.dataset.blissUi = "";
host.style.cssText = "position:fixed;inset:0;z-index:2147483646;background:rgba(0,0,0,.45)";
host.onclick = (event) => { if (event.target === host) host.remove(); };
on(document, "bliss:inspector-close", () => host.remove());
document.body.append(host);
htmx.ajax("GET", url, { target: host, swap: "innerHTML" });
}
// T R E E -- the list of everything the current page rendered.
function sourceButton(label, kind, id, count) {
const button = document.createElement("button");
button.type = "button";
button.className = "block w-full border border-transparent px-2 py-1 text-left hover:border-black hover:bg-black hover:text-white";
button.textContent = label + (count > 1 ? ` (${count})` : "");
button.onmouseenter = () => highlight(kind, id, null, true);
button.onmouseleave = () => highlight(kind, id, null, false);
if (kind !== "structure") button.onclick = () => openEditor(kind, id);
return button;
}
function renderTree(items) {
tree.innerHTML = "";
const structures = new Map();
for (const item of items) {
if (!structures.has(item.structureId)) structures.set(item.structureId, { name: item.structureName, routes: new Map(), templates: new Map(), count: 0 });
const structure = structures.get(item.structureId);
structure.count++;
if (!structure.routes.has(item.routeId)) structure.routes.set(item.routeId, { name: item.routeName, count: 0 });
structure.routes.get(item.routeId).count++;
if (item.templateId) {
if (!structure.templates.has(item.templateId)) structure.templates.set(item.templateId, { name: item.templateName, count: 0 });
structure.templates.get(item.templateId).count++;
}
}
for (const [structureId, structure] of structures) {
const group = document.createElement("details");
group.open = true;
const heading = document.createElement("summary");
heading.className = "cursor-pointer list-none font-semibold";
heading.append(sourceButton(structure.name, "structure", structureId, structure.count));
group.append(heading);
for (const [label, kind, entries] of [["Routes", "route", structure.routes], ["Templates", "template", structure.templates]]) {
if (!entries.size) continue;
const section = document.createElement("details");
section.open = true;
section.className = "ml-3";
const summary = document.createElement("summary");
summary.className = "cursor-pointer py-1 text-xs font-bold uppercase tracking-widest";
summary.textContent = label;
section.append(summary);
for (const [id, entry] of entries) section.append(sourceButton(entry.name, kind, id, entry.count));
group.append(section);
}
tree.append(group);
}
}
// O V E R L A Y -- little edit/copy/clone toolbars drawn on the page itself,
// one per rendered element, anchored to its top-left corner.
const layer = document.createElement("div");
layer.dataset.blissUi = "";
layer.style.cssText = "position:fixed;inset:0;z-index:2147483640;pointer-events:none";
root.parentNode.append(layer); // lives inside the inspector root, so it is torn down on close
const bars = new Map();
function sourceEls() {
return [...document.querySelectorAll("[data-bliss-structure-id],[data-bliss-template-id]")]
.filter((element) => !element.closest("[data-bliss-ui]"));
}
function templatesOf(element) {
try { const list = JSON.parse(element.dataset.blissTemplates || "[]"); if (list.length) return list; } catch (_) {}
return element.dataset.blissTemplateId
? [{ id: element.dataset.blissTemplateId, name: element.dataset.blissTemplateName }]
: [];
}
function iconButton(glyph, title, kind, id, method, action) {
const button = document.createElement("button");
button.type = "button";
button.textContent = glyph;
button.title = title;
button.style.cssText = "pointer-events:auto;border:2px solid #000;background:#fff;border-radius:6px;min-width:20px;height:20px;padding:0 2px;font-size:11px;line-height:16px;cursor:pointer;box-shadow:1px 1px 0 #000";
if (kind) {
button.onmouseenter = () => highlight(kind, id, method, true);
button.onmouseleave = () => highlight(kind, id, method, false);
}
button.onclick = (event) => { event.preventDefault(); event.stopPropagation(); action(button); };
return button;
}
function buildBar(element) {
const wrap = document.createElement("div");
wrap.dataset.blissUi = "";
wrap.style.cssText = "position:absolute;display:flex;gap:3px;pointer-events:none";
if (element.dataset.blissRouteId) {
wrap.append(iconButton("✏️", "Edit route " + (element.dataset.blissRouteName || ""),
"route", element.dataset.blissRouteId, element.dataset.blissMethod,
() => openEditor("route", element.dataset.blissRouteId)));
}
for (const template of templatesOf(element)) {
wrap.append(iconButton("📄", "Edit template " + (template.name || ""),
"template", template.id, null, () => openEditor("template", template.id)));
}
if (element.dataset.blissCopy) {
wrap.append(iconButton("📋", "Copy embed snippet", null, null, null, async (button) => {
try { await navigator.clipboard.writeText(element.dataset.blissCopy); button.textContent = "✅"; }
catch (_) { button.textContent = "❌"; }
setTimeout(() => (button.textContent = "📋"), 1000);
}));
}
if (element.dataset.blissClone) {
wrap.append(iconButton("👯", "Clone structure", null, null, null,
() => openModal(element.dataset.blissClone)));
}
return wrap;
}
function place(element, wrap) {
const rect = element.getBoundingClientRect();
if (!rect.width && !rect.height) { wrap.style.display = "none"; return; }
wrap.style.display = "flex";
// Keep the toolbar clear of the slideout: the page root (<body>) is not
// itself shifted, so its top-left would otherwise hide behind the panel.
const guard = root.getBoundingClientRect().right + 2;
const top = rect.top - 24;
wrap.style.left = Math.max(guard, rect.left + 2) + "px";
wrap.style.top = (top < 2 ? rect.top + 2 : top) + "px";
}
function syncBars() {
const live = new Set();
for (const element of sourceEls()) {
live.add(element);
let wrap = bars.get(element);
if (!wrap || !wrap.isConnected) { wrap = buildBar(element); bars.set(element, wrap); layer.append(wrap); }
place(element, wrap);
}
for (const [element, wrap] of bars) {
if (!live.has(element)) { wrap.remove(); bars.delete(element); }
}
}
let frame = 0;
function reposition() {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => { for (const [element, wrap] of bars) place(element, wrap); });
}
on(window, "scroll", reposition, true);
on(window, "resize", reposition);
on(document, "bliss:inventory", (event) => { renderTree(event.detail); syncBars(); });
on(document, "bliss:inspector-close", () => window.__blissLive && window.__blissLive());
document.dispatchEvent(new CustomEvent("bliss:inventory-request"));
})();
</script>

View file

@ -1,25 +0,0 @@
<div data-bliss-ui>
<div class="mb-2 flex items-center justify-between border-b-4 border-double border-black pb-2"><strong><%= it.sourceTemplate.name %></strong><span data-status class="text-xs"></span></div>
<div data-source class="h-[55vh] w-full border-2 border-black"><%= it.sourceTemplate.content %></div>
<button data-save type="button" class="mt-2 border-2 border-black bg-white px-3 py-2 font-bold shadow-[3px_3px_0_#000] active:translate-x-0.5 active:translate-y-0.5 active:shadow-none">Save Template</button>
</div>
<script>
(function () {
const box = document.currentScript.previousElementSibling;
if (!box) return;
const editor = ace.edit(box.querySelector("[data-source]"));
editor.setTheme("ace/theme/monokai");
editor.session.setMode("ace/mode/html");
box.querySelector("[data-save]").onclick = async () => {
const status = box.querySelector("[data-status]");
status.textContent = "Saving…";
const response = await fetch("/workshop/<%= it.sourceTemplate.structure_id %>/template/<%= it.sourceTemplate.id %>", {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: editor.getValue() })
});
if (!response.ok) return void (status.textContent = "Save failed");
status.textContent = "Saved";
document.dispatchEvent(new CustomEvent("bliss:source-saved", { detail: { kind: "template", id: "<%= it.sourceTemplate.id %>" } }));
};
})();
</script>

View file

@ -1,7 +0,0 @@
function handler(req, res) {
const sql = require("db")("bliss").sql;
const kind = "template";
const artifact = sql.prepare("SELECT * FROM templates WHERE id = ?").get(req.params.id);
if (!artifact) return res.status(404).send("Template not found");
res.render("inspector/artifact_editor", { kind, artifact });
}

View file

@ -1,558 +0,0 @@
# Chat Rearchitecture — "AIM 2005" edition
> Living design doc **and** build log. Section 18 is the plan; Section 10 is the
> running notes I update as I build. Keep it honest: record what actually
> happened, not what was supposed to.
---
## 0. Prime directive (read this first)
**We are building fun interfaces at the expense of security and safety.** This is
a hypermedia playground, not a hardened product. Concretely:
- **Messages may contain arbitrary HTML/JS and we render it unescaped, forever.**
`<%~ it.content %>` (Eta raw) stays. A message can `<script>`, restyle the
page, animate, embed another Bliss route, whatever. This is a *feature* and the
single most important invariant to preserve. Do not "fix" it. Do not sanitize.
- We optimize for looseness, composability, and delight over correctness
guarantees. When a choice trades safety for a more slippery/joyful interface,
take the fun one and leave a note.
Everything below serves that directive.
---
## 1. Goals (what the user asked for)
1. **Look like AOL Instant Messenger, ~2005.** Really copy it. The current UI is
a generic 98.css window with lime-green message area and huge text — wrong.
Blue-vs-red color-coded screen names, timestamps, silver beveled chrome,
Times New Roman, smiley toolbar. **Text is currently way too big — shrink it.**
2. **Reliable, quiet presence.** Login/logout ("signed in / signed out") notices
are unreliable and noisy today. Make them consistent and calm.
3. **Remember who you are.** Today it forgets your identity constantly. Should
persist across reloads via **localStorage**, and **default to the logged-in
Bliss user's username** when there's no saved handle.
4. **Web-push notifications that actually work.** Today they're half-broken: push
is tied to the browser *installation*, but identity churns, so **you get push
notifications for your own messages.** Fix the self-notify bug and make
delivery consistent.
5. **Clean rearchitecture** with **stricter template separation** — pieces that
are well-isolated and independently **embeddable**.
6. **Event-driven composition like the `sheepgpt` demo.** Components listen for a
**message-send** event and emit a **message-received** event, scoped to their
own little **uuid/zone** (optional query param on the whole-page GET,
propagated down to sub-components).
7. **Use individual pieces in isolation:**
- a **live chat view** with *no* compose form,
- a **compose form** with *no* live chat,
- a **frozen** chat (a fixed time range / snapshot),
- a **single message** — a bona fide Bliss route you `hx-get`.
8. When a new message arrives, the **WebSocket should just tell clients to
`hx-get` that message's route** and render it — the socket is a notification
bus, not a renderer. (First render may `hx-get` everything; that's fine.)
9. Overall feel: **loose, slippy, beautiful, simple, composed of small parts, and
perfect.**
---
## 2. Current chat (`structure 18`, prefix `/chat`) — inventory & diagnosis
Read from the live brb.city instance via the `bliss` CLI. Snapshot of what
exists and *why it misbehaves*.
### Routes
| id | verb | path | role |
|----|------|------|------|
| 25 | GET | `/` | full page (`chat` template) |
| 22 | GET | `/room` | (legacy poll) |
| 34 | WS | `/room` | the socket: message / join / catchup, push fan-out |
| 21 | GET | `/message/:id` | render one message (`getMessageById` is **broken**, see below) |
| 23 | POST | `/message` | *mislabeled* — actually saves a push subscription |
| 45 | POST | `/push-subscribe` | saves push subscription onto `alias_id` |
| 24 | GET | `/send-box` | a compose box fragment |
| 47 | GET | `/messages` | list fragment |
| 50 | POST | `/username` | rename alias |
| 51 | GET | `/edit` | settings window (rename + enable notifs) |
| 48 | GET | `/manifest.json` | PWA manifest |
| 58 | GET | `/service-worker.js` | push SW |
| 27/28/33 | WS/GET | `/testing`,`/wstest`,`/sdfsdf` | dead scaffolding |
### Templates
`chat` (page), `room`, `message`, `message-raw`, `messages`, `send-box`, `input`,
`join input`, `sign in message`, `success`, `edit-username`, `catch up messages`,
`secret recent message form`, `secret auto login form` (**empty!**), `say bridge`.
### DB (`chat`, db id 2)
`aliases(alias_id, user_id, username, created_at, updated_at, push_subscription)`
and `messages(message_id, content, alias_id, created_at, updated_at)`.
Library helpers: `createAlias`, `getOrCreateAlias`, `createMessage`,
`getAllMessages(before, after, limit)`, `getMessageById` (**bug**),
`updateUsername`, `updateAliasSubscription`, `getAliasSubscription`, etc.
### Root-cause diagnosis (the bugs, precisely)
- **Forgets you / new identity every join.** The `join` event calls
`createAlias(username)` — a *brand-new row every time*, stored in
`req.session.alias_id`. There is no localStorage, and the `secret auto login
form` template that was meant to re-join is **literally empty**. So every fresh
session (new tab, cleared cookie, expired session) = a new stranger.
- **Self-notify push bug.** Push subscription is written onto whatever
`alias_id` is in session *at subscribe time*. Later your session's `alias_id`
changes (see above), so `sendPushNotifications(msg, senderAliasId)` excludes
the *new* alias while your subscription still lives on the *old* alias →
**the server pushes your own message back to you.** Push is keyed by ephemeral
identity instead of by a stable device/endpoint.
- **Noisy/unreliable presence.** `broadcastSigning` fires on every socket
`open`/`close`. With per-embed sockets, reconnects, and multiple tabs, you get
a storm of "guest signed in / signed out". Presence is tied to raw socket
lifecycle, not to a debounced notion of a *person* being present.
- **`getMessageById` is broken SQL** (`message.id` should be
`messages.message_id`, missing `message_id`/`created_at` selects) — so the
"render a single message by route" primitive (goal #8) doesn't actually work.
- **Text too big.** `chat` uses `text-xl`; message area is `bg-lime-400`; message
bubbles are `bg-black text-white rounded-lg` — a modern chat-bubble look, the
opposite of AIM.
- **Weak separation.** The page hard-codes `/chat/...` URLs, mixes transport +
UI + PWA + push all in one `chat` template, and has no zone/uuid scoping, so
you can't drop "just the live view" or "just the compose box" somewhere else
cleanly, and two instances on one page would collide on DOM ids
(`#chat_room`, `#form`, ...).
---
## 3. The AIM 2005 aesthetic (design target)
Reference: classic AIM IM window. Defining traits (from memory + research):
- **Silver/gray beveled window chrome**, blue gradient title bar reading
something like **"Instant Message"** with a little running-man logo vibe.
- **Message transcript area: white background**, small text, **Times New Roman**
(or the browser serif). Each line:
`**ScreenName** (h:mm:ss AM/PM): message` — the **screen name is bold and
color-coded: blue for you, red for the other person(s)**, timestamp in gray.
- A **formatting toolbar** strip (A font, size, **B** *I* <u>U</u>, color swatch,
smiley face) above the compose box — mostly decorative but should *look* right.
Text emoticons (`:-)`, `;-)`, `:-P`) → classic yellow smileys is a nice-to-have.
- **Compose area**: a bordered text box + a chunky **Send** button (Enter sends).
- Small system lines for presence: `ScreenName signed on.` / `signed off.` in
gray italics, not bubbles.
- **Small text everywhere** (~1213px). This directly fixes "text too large".
Implementation: hand-rolled CSS in the page template's `<head>` (self-contained,
no external CDN beyond what Bliss already injects). Keep it a single small
stylesheet so embeds inherit it, or scope it so isolated embeds can bring their
own. Decide in Section 7.
---
## 4. Architecture — small parts, one event bus, one uuid
### 4.1 The zone (uuid)
Every whole-page render accepts an optional `?zone=<uuid>` query param (generate
one if absent). The zone is:
- a **DOM-id namespace** — every element id is suffixed `-<zone>` so two chat
instances can share a page without colliding (this is what the current code
can't do), and
- an **event namespace** — the custom events are `chat:send:<zone>` /
`chat:message:<zone>` *or* carry `detail.zone` and listeners filter. (Leaning
toward `detail.zone` filtering + a bare `chat:send` / `chat:message` name, so
cross-zone bridging is possible on purpose. TBD in build.)
The page GET propagates `zone` down into every embedded sub-component's `hx-get`
URL, exactly like `sheepgpt` propagates `zone` into the sheep embed.
### 4.2 The event model (like sheepgpt's `chat:say`)
Two DOM custom events, dispatched on `document.body`, bubbling:
- **`chat:send`** — "please send this message." `detail: { zone, name?, content }`.
The compose form dispatches it; *anything* can dispatch it (that's the
composability port — same idea as the existing `say bridge` / sheepfriend
`chat:say`). A hidden `ws-send` bridge catches it and pushes it over the socket.
→ We keep a `chat:say` alias for backwards-compat with sheepfriend.
- **`chat:message`** — "a message was received/rendered." `detail: { zone,
message_id, name, content }`. Emitted **by the client** when a new message
lands in the transcript (so sibling embeds / bots can react — e.g. sheep hears
it). This is the **message-received** event the user asked for.
So: **listen for send, emit received.** A live view with no compose box still
emits `chat:message`. A compose form with no live view still dispatches
`chat:send`. They only need to share a `zone` (or a socket) to talk.
### 4.3 The socket is a notification bus, not a renderer
New-message flow (goal #8):
1. Client dispatches `chat:send` → hidden `ws-send` form → socket.
2. Server WS handler: dedup, persist via `createMessage`, get `message_id`.
3. Server broadcasts to all room clients a tiny fragment that is **just an
`hx-get` of `/message/:id`** (out-of-band swap appending into the transcript):
```html
<div id="transcript-<zone>" hx-swap-oob="beforeend">
<div hx-get="/aim/message/ID?zone=ZONE" hx-trigger="load" hx-swap="outerHTML"></div>
</div>
```
The socket never ships message *markup* — only a pointer. The `/message/:id`
route is the single source of truth for how a message looks.
4. `/message/:id` renders the message (unescaped content!) and, on load, dispatches
`chat:message` for that zone.
First page render is allowed to `hx-get` everything (each message its own lazy
`hx-get`, or one `messages` batch — batch on first paint for speed, individual
`hx-get` for live arrivals). **This makes "a single message" a real, linkable,
independently-editable Bliss route** — the whole point.
### 4.4 The isolated, embeddable pieces (each a GET route)
| route | what it renders | form? | live? |
|-------|-----------------|-------|-------|
| `GET /` | full IM window (chrome + transcript + toolbar + compose) | yes | yes |
| `GET /live` | transcript only, ws-connected, **no compose** | no | yes |
| `GET /compose` | compose form only (+ `chat:send` bridge), **no transcript** | yes | no |
| `GET /frozen?before=&after=` | a static snapshot of a time/id range, **no ws** | no | no |
| `GET /message/:id` | exactly one message | no | no |
All accept `?zone=`. All are droppable into any other template via the standard
Bliss embed (`<div hx-get="/aim/live?zone=abc" hx-trigger="load"></div>`), which
is what the inspector's 📋 copy button already produces. Compose that owns no
transcript still works because `chat:send` rides the shared socket/zone.
---
## 5. Identity, "remember me", localStorage
Identity resolution order when the page loads (highest priority first):
1. **localStorage** `aim:handle` (what you last called yourself), and
`aim:client_id` (a stable per-browser UUID minted once).
2. **Logged-in Bliss user's username** (default when no saved handle). Requires
exposing the current user to the sandbox — see Section 6.
3. `"guest"` fallback.
Rules:
- **One stable alias per handle**, via `getOrCreateAlias(username)` (already
exists) — never `createAlias` on every join. Renaming updates the row.
- The client mints `aim:client_id` (UUID) once and **sends it with every send and
with every push-subscribe**. This is the *device/identity* key that push
exclusion uses, decoupled from the churny `alias_id`. Fixes self-notify.
- Auto-rejoin on load: if `aim:handle` exists, silently register it into the
session (no visible "join" form unless the user is truly new *and* not logged
in). The empty `secret auto login form` template gets a real implementation.
- Changing your name writes `aim:handle` and updates the alias; presence and push
keep working because they key on `client_id`, not the name.
---
## 6. Push notifications — rearchitecture
Problems: keyed on ephemeral `alias_id`; self-notify; no dedup by endpoint.
Design:
- **Subscriptions keyed by `client_id` (stable) and deduped by push `endpoint`.**
New table (or reuse `aliases.push_subscription` but add a `push_subs` table):
`push_subs(client_id TEXT, endpoint TEXT PRIMARY KEY, subscription TEXT,
handle TEXT, updated_at)`. Endpoint is globally unique per browser+push
service, so re-subscribing updates in place instead of duplicating.
- **On new message**, fan out to every subscription **whose `client_id != the
sender's `client_id`.** The sender's `client_id` travels with the `chat:send`
payload. → You never get pushed your own message, even if your display name
changed, session reset, whatever.
- Keep the service worker's "app is visible → suppress + clear notifications"
behavior (it's good), but make the visibility ping reliable.
- Payload: `{ title: name, body: content, zone }` so the SW can render
`Name: message` (AIM-ish) and optionally focus the right zone on click.
- Handle `410 Gone` → delete that endpoint row (self-cleaning).
**Exposing the logged-in user (needed for §5 default):** the sandbox context
(`bootstrapContext` in `index.js`) currently exposes `req`, `res`, `eta`,
`vapidPublicKey`, etc. `req.session.userId` is present but the *username* is not
resolvable inside a structure (no access to the main `users` table). Plan:
inject a read-only `req.currentUser = { id, username }` (looked up via
`model.getUser`) in the `app.all("*")` handler and the WS dispatch, so **every**
structure can greet the logged-in user. This is the one `index.js` change and it
benefits the whole platform (restart required to pick it up). *Decision pending —
see Open Questions.*
---
## 7. Template separation — beefed up
Principles:
- **One concern per template.** No template both connects a socket *and* paints a
message *and* registers push. Split: `page-chrome`, `transcript`, `message`,
`compose`, `toolbar`, `presence-line`, `ws-bridge`, `push-setup`,
`identity-boot` (localStorage/auto-login), `styles`.
- **Every rendered id is zone-suffixed.** No bare `#chat_room` / `#form`.
- **Templates take explicit args, never reach into globals.** A template that
needs the zone gets `it.zone`; one that needs a message gets the message fields.
This is what makes them embeddable in isolation.
- **URL building goes through `route()`** (already available) so prefix changes
don't break embeds; never hard-code `/chat/...`.
- **`styles` is one self-contained AIM stylesheet**, included by full-page
renders; isolated embeds (`/live`, `/compose`) include a slim shared style
partial so they look right on their own too.
- Keep raw/unescaped message rendering isolated in exactly one place
(`message` template) so the "arbitrary HTML/JS" power is obvious and auditable
(auditable for *fun*, not for locking down).
---
## 8. Proposed new structure — route/template map
Build as a **new structure** (working name **`aim`**, prefix `/aim`) so the live
`/chat` keeps working until this is proven. Clone the `chat` db (or attach it) so
history carries over — decide whether to share the same SQLite file or start
clean (see Open Questions).
**Routes**
- `GET /` — full IM window. Accepts `?zone=`.
- `GET /live` — transcript-only live view (ws, no compose).
- `GET /compose` — compose-only (dispatches `chat:send`).
- `GET /frozen` — static range snapshot (`?before=&after=` or `?since=`).
- `GET /message/:id` — one message (fixes `getMessageById`).
- `WS /room` — socket bus: `send`(→persist→broadcast hx-get pointer),
`presence`, `catchup`. Dedup named/bot messages.
- `POST /push-subscribe` — upsert `push_subs` by endpoint, keyed by `client_id`.
- `POST /rename` — set handle (updates alias, echoes to localStorage client-side).
- `GET /manifest.json`, `GET /service-worker.js` — PWA/push.
**Templates**
`page`, `styles`, `transcript`, `message`, `compose`, `toolbar`,
`presence-line`, `ws-bridge` (catches `chat:send`, ships over socket),
`msg-pointer` (the tiny hx-get fragment the socket broadcasts), `identity-boot`
(localStorage + auto-login + emits `chat:message` wiring), `push-setup`.
**DB** — extend `chat` library with: `getMessageById` (fixed), `messagesInRange`,
`upsertPushSub(client_id, endpoint, sub, handle)`, `deletePushSub(endpoint)`,
`subsExcludingClient(client_id)`. Add `push_subs` table via a migration fn in the
library (the existing pattern: idempotent `CREATE TABLE` helpers).
---
## 9. Phased implementation plan
- **Phase 0 — Doc & scaffold.** This file. Create `aim` structure, attach/clone
`chat` db. ← *you are here*
- **Phase 1 — Data & single message.** Fix `getMessageById`, add range +
push-sub helpers + `push_subs` migration. Ship `GET /message/:id` + `message`
template with correct AIM line formatting and **unescaped content**. Verify a
single message renders in isolation.
- **Phase 2 — Live view + socket-as-bus.** `GET /live` + `transcript` +
`WS /room` broadcasting only `msg-pointer` hx-gets. Confirm new messages appear
by the client hx-getting `/message/:id`. Emit `chat:message` on arrival.
- **Phase 3 — Compose + event bus.** `GET /compose` + `ws-bridge` listening for
`chat:send` (and legacy `chat:say`). Confirm compose-only and live-only work
separately, and together via shared `zone`.
- **Phase 4 — Identity.** `identity-boot`: localStorage handle + client_id,
auto-rejoin, default to logged-in Bliss user. `POST /rename`. (Depends on the
`req.currentUser` decision.)
- **Phase 5 — Presence.** Debounced, deduped signed-on/off lines keyed by person,
not socket. Quiet and consistent.
- **Phase 6 — Push.** Rework subscription storage + self-notify fix + SW payload.
Test on two devices / two client_ids.
- **Phase 7 — AIM chrome & polish.** Full `styles`, toolbar, smileys, small text.
`GET /` assembles all pieces. `frozen` view.
- **Phase 8 — Compose demo.** A page that nests `/live` + `/compose` + maybe the
sheep, proving the parts compose (an `aimgpt` analog to `sheepgpt`).
Each phase: build with the `bliss` CLI, then verify against brb.city (or local),
then log results in Section 10 before moving on.
---
## 10. Build log / running notes
> Newest entries at the bottom. Record commands, ids, surprises, decisions.
- **2026-08-06 — Phase 0 kickoff.**
- Instance under edit: **brb.city** (live; CLI sticky target). Building a *new*
structure rather than mutating `#18` so production `/chat` stays up.
- Fully inventoried `chat` #18 (routes/templates/db) and the `sheepgpt` #42 +
`sheepfriend` #26 event pattern. Root-caused all four reported bugs
(forgets-you, self-notify push, noisy presence, + found broken
`getMessageById`). Written up in §2.
- No standalone "bliss MCP" server exists in this session; driving Bliss via the
`bliss` CLI per project convention.
- _Next:_ resolve Open Questions (esp. shared vs. fresh DB, and the
`req.currentUser` index.js change), then Phase 1.
- **2026-08-06 — Decisions locked + scaffold built.**
- Decisions: **new `aim` structure**, **fresh DB**, **minimal handcrafted
`req.currentUser` (`{id, username}`)** in index.js (see §11).
- Created structure **`aim` = id 44**, prefix `/aim`, db **`chat` = id 12**.
- **Route id map:** `GET /`=145, `GET /live`=146, `GET /compose`=147,
`GET /frozen`=148, `GET /message/:id`=149, `WS /room`=150,
`POST /push-subscribe`=151, `POST /rename`=152, `GET /manifest.json`=153,
`GET /service-worker.js`=154.
- **Template id map:** `styles`=124, `message`=125, `transcript`=126,
`compose`=127, `toolbar`=128, `presence-line`=129, `ws-bridge`=130,
`msg-pointer`=131, `identity-boot`=132, `push-setup`=133, `page`=134,
`frozen`=135.
- **DB library written & smoke-tested** (Phase 1 data layer): fresh schema
(`aliases`, `messages`, `push_subs`), `getMessageById` fixed, range/catchup
helpers, push-sub upsert keyed by endpoint + `subsExcludingClient`. Verified
alias stability + verbatim HTML storage via REPL, then cleared test rows.
- _Next:_ `styles` + `message` template + `GET /message/:id` (single-message
primitive).
- **2026-08-06 — Phases 17 built & backend verified on brb.city.**
- **All 16 templates + 10 routes written** (ids in the maps above). Structure
`aim` #44 is live at `https://brb.city/aim/`.
- **Design pivot (recorded so §46 match reality): identity is fully
client-side.** localStorage holds `aim:handle` + `aim:client_id` (a stable
per-browser UUID). The socket carries them on an `identify` event (presence)
and each `send` (authorship + push exclusion). The server *trusts* the
client's declared identity — correct for a fun playground, and it kills the
"forgets who you are" + session-churn class of bugs outright. No server-side
alias/session juggling remains.
- **The four reported bugs, resolved:**
- *Forgets you* → localStorage handle + one stable alias per handle
(`getOrCreateAlias`), auto-seeded from the logged-in Bliss user.
- *Self-notify push* → subscriptions keyed by `endpoint`, owned by stable
`client_id`; fan-out is `subsExcludingClient(senderClientId)`. Your own
device is structurally excluded regardless of name/session changes.
- *Noisy presence* → counts kept **per person, not per socket**, with a 4s
debounce on sign-off, so tabs/reconnects don't spam. (`arrive`/`depart` in
the WS handler.)
- *Broken single-message*`getMessageById` rewritten with the correct join;
it's now the backbone of the socket-as-pointer-bus design.
- **Socket-as-bus confirmed live** via a Node `ws` client: `identify`
presence-line broadcast; `send` → persisted (`message #4`) + a `msg-pointer`
broadcast of exactly
`<div hx-get="/aim/message/4?zone=z9" hx-trigger="load" hx-swap="outerHTML">`
— no message markup crosses the socket. `GET /aim/message/1` renders the AIM
line (red bold name, gray timestamp) with **unescaped** `<b>`/`<marquee>`
intact. First-render null-slice bug (Eta ASI: an eval block must not start
with `(`) found & fixed in `transcript`.
- **`req.currentUser` added** (minimal `{id, username}`) in **local**
`index.js` + `getUserById` in `db.js` (syntax-checked). ⚠️ This repo is the
*local* copy; brb.city runs from `/home/nodejs/bliss2` on another host — **the
index.js/db.js change needs deploy + restart there to take effect.** Until
then templates fall back to `"Guest"` (graceful; nothing breaks).
- Seeded one welcome message; cleared all test rows.
### Verified ✅ vs. remaining ⏳
- ✅ Fresh schema + library (alias stability, verbatim HTML, range/catchup/push helpers)
- ✅ `GET /message/:id` single-message primitive (unescaped content)
- ✅ `WS /room`: identify→presence, send→persist→pointer broadcast, catchup
- ✅ Full page, `/live`, `/compose`, `/frozen` render without error
- ✅ Socket ships pointers only; message route is the single render source
- ⏳ **Browser check** (Playwright MCP wasn't available this session): the
client paths — compose→`chat:send`→ws-bridge ship, `chat:message` emit on
arrival, localStorage default handle, rename, autoscroll, push subscribe —
are written but not yet driven in a real browser. Do this next session.
- ⏳ **Deploy** local `index.js`/`db.js` to brb.city + restart (pm2 `foo`) to
enable the logged-in-user default.
- ⏳ **Phase 8**: an `aimgpt`-style demo page nesting `/live` + `/compose` (+ the
sheep) to show the parts compose.
- **2026-08-06 — Browser verification + the send-path bug (found via Playwright).**
- Got Playwright MCP working (it wanted chromium build 1237; symlinked the
installed `chromium-1234``chromium-1237` in `~/.cache/ms-playwright`).
- **Sending was broken — real bug, now fixed.** Eta's `<%= %>` HTML-escapes, so
`var dflt = <%= JSON.stringify('Guest') %>;` rendered as
`var dflt = &quot;Guest&quot;;` **inside the `<script>`** → "Unexpected token
'&'" → the entire `ws-bridge` script threw → `window.aimId` undefined → no
`chat:send` listener → messages dispatched into the void (textarea cleared,
nothing shipped). Fix: use `<%~ %>` (raw) for JSON in every script context
(`ws-bridge`, `identity-boot`, `push-setup`).
- Also fixed: `_hyperscript` has **no ternary** (`?:`) — the screen-name span
`set my innerHTML to (window.aimId ? … : …)` threw. Replaced with a plain-JS
`.aim-screenname` painter in `identity-boot`.
- **Verified in a real browser (0 console errors):** typed a message, pressed
Enter → full round trip (compose → `chat:send` → ws-bridge ship → persist →
`msg-pointer` broadcast → `hx-get /message/2` → rendered). Presence line
"Guest signed on." shows. AIM aesthetic confirmed via screenshot
(`aim-working.png`): blue title bar, silver bevel, small serif transcript,
color-coded bold names, gray timestamps, toolbar, compose. `<marquee>` runs.
- Note: the Bliss inspector 🔍 button floats over the Send button (Enter still
sends). Handle still defaults to "Guest" until the `req.currentUser` change
is deployed to brb.city.
- **2026-08-06 — Mobile pass.**
- Root cause of "text too small on mobile": **no `<meta viewport>`** — page
rendered at desktop width and scaled down. Added a mobile head-injection on
structure 44 (viewport with `user-scalable=no` + `interactive-widget=resizes-content`,
theme-color, apple PWA metas, `touch-action:manipulation` to kill double-tap
zoom, favicon + apple-touch-icon links).
- Bumped fonts (16px base / 13px timestamps / 13px titlebar), enlarged compose
(3 rows, min-height 84px, 16px to avoid iOS focus-zoom), safe-area insets on
titlebar/compose.
- **AIM favicon:** wget'd the real AIM running-man logo (user OK'd for the test
instance), generated 32/180/192/512 + `.ico` via ImageMagick, uploaded to
structure 44 (served `brb.city/44/*`), wired manifest icons + head links.
- **Keyboard resize (user chose PURE CSS, zero JS):** layout already shrinks
via the flex column (`.aim-transcript { flex:1; min-height:0; overflow }`) +
`height:100dvh`; `interactive-widget=resizes-content` makes Android shrink
the viewport with no JS. iOS Safari has no CSS-only fix, so it falls back to
the browser's shift-up. (Briefly tried a `visualViewport` script — verified
it shrank 844→544px — then removed it per the clean-CSS decision.)
- **2026-08-06 — File upload (frontend compression).**
- **Architecture:** compression is 100% frontend (the handler sandbox exposes
no fs/sharp/ffmpeg). Images: canvas downscale + JPEG quality loop to <1MB
(verified: crushed a 21MB PNG). Videos: **ffmpeg.wasm** transcode to H.264/AAC
mp4 targeting a bitrate from clip duration (verified: **4.6MB → 986KB**,
playable 640×360). Other types: uploaded as-is → inline `<a>` link.
Images/videos embed as `<img>`/`<video>`. Toolbar `attach` control.
- **Upload path = disk.** `POST /upload``require('files').saveFile`
`public/<sid>/<name>`, served at `/<sid>/<name>`; returns same-origin path.
Found + fixed a framework bug: `saveFile` did `path.join(__dirname,"public",
structureId)` with a **numeric** id (only worked from the workshop route
which passes a string) → `String()` coercion in `index.js` (reproduced
before/after). ⚠️ **Needs the index.js deploy to brb.city** for uploads to
persist live (same deploy as `req.currentUser`).
- **ffmpeg.wasm hosting:** the 5 assets (ffmpeg.js, 814.ffmpeg.js worker,
util, core.js, 32MB core.wasm) are uploaded to the structure's files
(`/44/*`, same-origin) and lazy-loaded on first video; the service worker
caches them so they load once. Key gotcha (cost several iterations): in
ffmpeg.js 0.12, passing `classWorkerURL` forces a **module** worker, but the
UMD worker chunk is **classic** (`importScripts`) → "failed to import
ffmpeg-core.js". Fix: load `ffmpeg.js` same-origin and **do NOT pass
classWorkerURL** — webpack auto publicPath resolves its default (classic,
same-origin) worker to `/44/814.ffmpeg.js`. core/wasm handed in as
`toBlobURL` blobs.
- iOS caveat: ffmpeg.wasm is memory-bound in WKWebView; short clips fine, very
large/long ones may fail. Documented, acceptable for the playground.
### How to embed the pieces (the payoff)
```html
<!-- live transcript, no compose -->
<div hx-get="/aim/live?zone=abc" hx-trigger="load"></div>
<!-- compose only, no transcript -->
<div hx-get="/aim/compose?zone=abc" hx-trigger="load"></div>
<!-- a frozen slice of history -->
<div hx-get="/aim/frozen?after=0&before=50" hx-trigger="load"></div>
<!-- one message -->
<div hx-get="/aim/message/1?zone=abc" hx-trigger="load"></div>
```
Rule: **one zone == one ws-bridge (socket).** Compose raw pieces together only
with distinct zones, or just use `GET /aim/` (which includes exactly one).
Cross-structure port preserved: dispatch `chat:say`/`chat:send` on `body` and a
bridge ships it (sheepfriend/sheepgpt keep working).
---
## 11. Decisions (resolved 2026-08-06)
1. **New structure `aim`** (prefix `/aim`). ✅ Production `/chat` #18 stays live
and untouched; switch over later once proven.
2. **Fresh DB.** ✅ Start clean — no message/alias history carried over. Schema
built from scratch for this design (messages, aliases, `push_subs`).
3. **Expose a minimal, handcrafted `req.currentUser` in `index.js`.** ✅ Do **not**
pass the raw Express/session user object into the sandbox. Construct a small
read-only plain object — `{ id, username }` (add fields only as a concrete
need appears) — looked up from the main `users` table via `model.getUser`.
Inject it in the `app.all("*")` handler (and WS dispatch). Requires a restart
of the live brb.city process (pm2 `foo`).
4. **Event scoping:** single event name + `detail.zone` filter (lets cross-zone
bridges like the sheep listen broadly). Confirm ergonomics during Phase 3.
5. **PWA/manifest/service-worker:** keep existing behavior; only change the push
*payload* and *keying* (client_id/endpoint).

99
db.js
View file

@ -2,9 +2,7 @@ const fs = require("fs");
const path = require("path");
const betterSqlite3 = require("better-sqlite3");
const { LRUCache } = require("lru-cache");
const { randomUUID } = require("node:crypto");
const { Eta } = require("eta");
const cheerio = require("cheerio");
const db = betterSqlite3("./dbs/0.sqlite");
@ -56,10 +54,6 @@ function getUser(username) {
return db.prepare("SELECT * from users where username = ?").get(username);
}
function getUserById(id) {
return db.prepare("SELECT id, username FROM users WHERE id = ?").get(id);
}
// _ _ _______ ______ ___ _ _______ __ __ _______ _______
// | | _ | || || _ | | | | || || | | || || |
// | || || || _ || | || | |_| || _____|| |_| || _ || _ |
@ -96,76 +90,6 @@ function getAllRoutes() {
}
const templateCache = new LRUCache({ max: 100 });
const templateContextCache = new LRUCache({ max: 2000, ttl: 30 * 60 * 1000 });
// The inspector needs the values that produced each rendered template, not a
// fresh request's values. Contexts frequently contain helpers (such as
// `route`) or cyclic request objects, neither of which belongs in HTML. Keep
// the JSON-shaped portion and omit the rest rather than making a page fail to
// render just because it cannot be inspected.
function inspectableContext(data) {
const seen = new WeakSet();
try {
return JSON.parse(
JSON.stringify(data ?? {}, (_key, value) => {
if (typeof value === "function" || typeof value === "undefined") {
return undefined;
}
if (typeof value === "object" && value !== null) {
if (seen.has(value)) return undefined;
seen.add(value);
}
return value;
}),
);
} catch (_) {
return {};
}
}
function annotateTemplateHtml(html, template, data) {
const context = inspectableContext(data);
const contextId = randomUUID();
templateContextCache.set(contextId, { templateId: String(template.id), context });
function annotate($, element) {
const current = $(element).attr("data-bliss-templates");
let templates = [];
if (current) {
try {
templates = JSON.parse(current);
} catch (_) {}
}
if (!templates.some((item) => String(item.id) === String(template.id))) {
templates.push({ id: String(template.id), name: template.name });
}
$(element).attr("data-bliss-templates", JSON.stringify(templates));
const rawContextIds = $(element).attr("data-bliss-template-context-ids");
let contextIds = {};
if (rawContextIds) {
try {
contextIds = JSON.parse(rawContextIds);
} catch (_) {}
}
contextIds[String(template.id)] = contextId;
$(element).attr("data-bliss-template-context-ids", JSON.stringify(contextIds));
if (!$(element).attr("data-bliss-template-id")) {
$(element).attr({
"data-bliss-template-id": String(template.id),
"data-bliss-template-name": template.name,
"data-bliss-template-context-id": contextId,
});
}
}
const lower = html.trimStart().toLowerCase();
if (lower.startsWith("<html") || lower.startsWith("<!doctype")) {
const $ = cheerio.load(html);
annotate($, $("body")[0]);
return $.html();
}
const $ = cheerio.load(html, null, false);
for (const child of $.root().children()) annotate($, child);
return $.html();
}
function getTemplater(structId) {
let etaInstance = templateCache.get(structId);
@ -178,13 +102,6 @@ function getTemplater(structId) {
etaInstance.readFile = function (templateAlias) {
return getTemplateContentByName(structId, templateAlias).content;
};
const etaRender = etaInstance.render;
etaInstance.render = function (templateName, data, meta) {
const html = etaRender.call(this, templateName, data, meta);
if (typeof templateName !== "string") return html;
const template = getTemplateByName(structId, templateName);
return template ? annotateTemplateHtml(html, template, data) : html;
};
templateCache.set(structId, etaInstance);
}
@ -328,7 +245,6 @@ function updateStruct(struct) {
function updateTemplate(template) {
update("templates", ["content", "name", "test_object"], template);
templateCache.delete(template.structure_id);
recordVersion("template", template.id, template.structure_id, template);
}
@ -342,12 +258,6 @@ function getTemplate(templateId) {
return db.prepare("SELECT * from templates where id = ?").get(templateId);
}
function getInspectableTemplateContext(contextId, templateId) {
const entry = templateContextCache.get(contextId);
if (!entry || entry.templateId !== String(templateId)) return null;
return entry.context;
}
function getTemplateContentByName(structId, name) {
return db
.prepare(
@ -356,12 +266,6 @@ function getTemplateContentByName(structId, name) {
.get(structId, name);
}
function getTemplateByName(structId, name) {
return db
.prepare("SELECT * from templates where structure_id = ? AND name = ?")
.get(structId, name);
}
function createTemplate(structureId, name, content, testObjectString) {
const id = db
.prepare(
@ -764,7 +668,6 @@ module.exports = {
getDbInstance,
getAllRoutes,
getUser,
getUserById,
createUser,
getStructures,
getStructure,
@ -782,9 +685,7 @@ module.exports = {
getTemplater,
getTemplates,
getTemplate,
getInspectableTemplateContext,
getTemplateContentByName,
getTemplateByName,
createTemplate,
getDbsForStructure,
getDb,

252
index.js
View file

@ -2,7 +2,7 @@ const fs = require("fs");
const util = require("util");
const vm = require("node:vm");
const path = require("path");
require("dotenv").config();
require('dotenv').config()
const express = require("express");
const session = require("express-session");
const fileUpload = require("express-fileupload");
@ -16,10 +16,10 @@ const app = express();
const _expressWs = require("express-ws")(app);
const bodyParser = require("body-parser");
const model = require("./db");
const PORT = process.env.PORT || 3000;
const PORT = 3000;
const db = model.db;
const wsRouter = express.Router();
const wsRouter = express.Router()
let viewpath = path.join(__dirname, "views");
let eta = new Eta({ views: viewpath, cache: false, autoEscape: true });
@ -36,12 +36,7 @@ let routeIndex = Object.freeze({
});
const wsConnections = new Map();
const INSPECT_OPTS = {
showHidden: false,
depth: null,
colors: false,
compact: false,
};
const INSPECT_OPTS = { showHidden: false, depth: null, colors: false, compact: false };
function inspectArgs(args) {
return args.map((v) => util.inspect(v, INSPECT_OPTS)).join(" ");
}
@ -72,8 +67,8 @@ app.use(
app.use("/", wsRouter);
app.use("/plumbing", require("./plumbing"));
const vapidPublicKey = process.env.VAPID_PUBLIC_KEY;
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY;
const vapidPublicKey = process.env.VAPID_PUBLIC_KEY
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY
// Configure web-push with your VAPID details
webPush.setVapidDetails(
@ -82,46 +77,11 @@ webPush.setVapidDetails(
vapidPrivateKey,
);
// KaiOS's push service only understands the legacy draft `aesgcm` content
// encoding; everyone else (iOS, Chrome, Firefox, modern Safari) uses the
// RFC 8291 standard `aes128gcm`, which is web-push's default. The subscription
// endpoint hostname tells us which is which, so we pick per-subscription and
// keep the standard as the default for anything we don't recognize.
const LEGACY_AESGCM_HOSTS = ["push.kaiostech.com", "kai.jiophone.net"];
function pushEncodingFor(subscription) {
try {
const host = new URL(subscription.endpoint).hostname;
return LEGACY_AESGCM_HOSTS.some((h) => host === h || host.endsWith("." + h))
? "aesgcm"
: "aes128gcm";
} catch {
return "aes128gcm";
}
}
// Transparent drop-in for the web-push module handed to structures via
// require('push'): identical API (setVapidDetails, generateVAPIDKeys, …) via
// the prototype chain, but sendNotification auto-selects the content encoding
// from the subscription endpoint unless the caller passed one explicitly.
const push = Object.create(webPush);
push.sendNotification = function (subscription, payload, options = {}) {
return webPush.sendNotification(subscription, payload, {
...options,
contentEncoding: options.contentEncoding || pushEncodingFor(subscription),
});
};
async function saveFile(structureId, req, uploadedFile, asset = false) {
async function saveFile(structureId, req, uploadedFile, asset=false) {
const name = uploadedFile.name;
const [mime_type, mime_subtype] = uploadedFile.mimetype.split("/");
// structureId may arrive as a number (route.structure_id is an INTEGER) when
// called from a sandboxed handler via require('files').saveFile — the workshop
// /files route passes a string param, which masked this. path.join demands
// strings, so coerce.
const sid = String(structureId);
const uploadPath = path.join(__dirname, "public", sid);
const storedPath = path.join(sid, uploadedFile.name);
const uploadPath = path.join(__dirname, "public", structureId);
const storedPath = path.join(structureId, uploadedFile.name);
fs.mkdirSync(uploadPath, { recursive: true });
@ -138,7 +98,7 @@ async function saveFile(structureId, req, uploadedFile, asset = false) {
let file = model.getFile(id);
file.url = prefixUrlWithHost(req, file.path);
return file;
return file
}
// A console whose log() mirrors to stdout and to the structure's logs table.
@ -155,12 +115,7 @@ function makeConsole(structureId, routeId) {
// returning its exports. Each library runs in its own context so nothing
// leaks between dbs.
function runLibrary(sql, librarySource, console) {
const libContext = vm.createContext({
sql,
console,
fetch,
module: { exports: null },
});
const libContext = vm.createContext({ sql, console, fetch, module: { exports: null } });
vm.runInContext(librarySource, libContext);
return libContext.module.exports;
}
@ -170,33 +125,16 @@ function makeLibs(structureId, console) {
const dbs = {};
for (let appDb of model.getDbsForStructure(structureId)) {
const sql = model.getDbInstance(appDb.id);
dbs[appDb.alias] = {
library: runLibrary(sql, appDb.library, console),
sql,
};
dbs[appDb.alias] = { library: runLibrary(sql, appDb.library, console), sql };
}
return {
eta: model.getTemplater(structureId),
db: (alias) => dbs[alias],
push: push,
push: webPush,
files: { saveFile: (...args) => saveFile(structureId, ...args) },
};
}
// A small, safe projection of the logged-in user for user-route sandboxes.
// Returns null when nobody is logged in. Never leak the password hash or the
// raw session — hand structures exactly what they need to say "hi <name>".
function currentUserFor(req) {
const id = req && req.session && req.session.userId;
if (!id) return null;
try {
const u = model.getUserById(id);
return u ? { id: u.id, username: u.username } : null;
} catch (e) {
return null;
}
}
function bootstrapContext(structureId, routeId, initContext) {
const structure = model.getStructure(structureId);
const console = makeConsole(structureId, routeId);
@ -223,6 +161,7 @@ function routeWithPrefix(route) {
return withPrefix(route.route_prefix, route.path);
}
function compileWebsocketHandler(route) {
try {
// Keep the existing WS handler context for compatibility. Restricting the
@ -307,7 +246,6 @@ wsRouter.ws("*", (ws, req) => {
});
ws.clients = clients;
req.currentUser = currentUserFor(req);
ws.render = (template, context = {}) => {
const structure = model.getStructure(route.structure_id);
context.route = makeRoute(structure);
@ -402,7 +340,6 @@ function headChrome(headInjection) {
<script src="/js/hyperscript.js"></script>
<script src="/js/tailwind.js"></script>
<script src="/js/htmx.js"></script>
<script src="/js/ace/ace.js"></script>
<script src="https://unpkg.com/htmx.org@1.9.12/dist/ext/ws.js"></script>
<script src="/js/bliss_inspector.js"></script>
<script>
@ -425,17 +362,7 @@ function blissAttrs(source) {
const attrs = {
"data-bliss-route": `/workshop/${source.structureId}/route/${source.routeId}`,
"data-bliss-clone": `/workshop/${source.structureId}/clone_modal/`,
"data-bliss-structure-id": String(source.structureId),
"data-bliss-structure-name": source.structureName,
"data-bliss-route-id": String(source.routeId),
"data-bliss-route-name": `${source.verb} ${source.path}`,
"data-bliss-method": source.verb,
"data-bliss-request-url": source.requestUrl,
};
if (source.templateId) {
attrs["data-bliss-template-id"] = String(source.templateId);
attrs["data-bliss-template-name"] = source.templateName;
}
if (source.copyUrl) attrs["data-bliss-copy"] = source.copyUrl;
return attrs;
}
@ -458,32 +385,15 @@ function decoratePage(html, { headInjection, source } = {}) {
return $.html();
}
// Carry rendered head content through HTMX with a neutral OOB wrapper. A
// literal <head> in an HTMX response is discarded by the browser's fragment
// parser, and HTMX does not discover a top-level <style hx-swap-oob>. The div
// survives long enough for HTMX to append its children to the host's real
// #head; the browser then keeps the valid style/link/meta children there.
function headOobSwaps(headHtml, headInjection) {
const $ = cheerio.load(
`<head>${headHtml || ""}${headInjection || ""}</head>`,
);
const contents = $("head").html();
return contents ? `<div hx-swap-oob="beforeend:#head">${contents}</div>` : "";
}
// HTMX partial: stamp provenance on the swapped-in body elements (an OOB swap
// loses its own attrs, so we descend into its children), then carry the child
// structure's rendered head and configured head injection into the host page.
// HTMX partial: stamp provenance on the swapped-in elements (an oob swap loses
// its own attrs, so we descend into its children) and append an out-of-band
// <head> so head injection still reaches the page.
function decorateFragment(html, { headInjection, source } = {}) {
// Parse as a document so browser-valid top-level styles and links are
// collected into head. `cheerio.load(..., null, false)` leaves those nodes as
// fragment siblings, which makes them get swapped into body and not apply.
const $ = cheerio.load(html);
const body = $("body");
const $ = cheerio.load(html, null, false);
const attrs = blissAttrs(source);
if (attrs) {
const targets = [];
for (const child of body.children()) {
for (const child of $.root().children()) {
if (child.attribs && child.attribs["hx-swap-oob"]) {
for (const grandchild of child.children) {
if (grandchild.attribs) targets.push(grandchild);
@ -494,29 +404,10 @@ function decorateFragment(html, { headInjection, source } = {}) {
}
for (const target of targets) Object.assign(target.attribs, attrs);
}
return body.html() + headOobSwaps($("head").html(), headInjection);
}
// `res.render()` commonly returns a complete document even when it is serving
// an HTMX request. HTMX swaps that document's body into the current page, so
// preserve the template provenance from its body on every swapped root before
// reducing it to a fragment. Without this, templates from an hx-get child
// appear to belong to the outer page; saving `message-raw` in /sheepgpt then
// incorrectly refreshes /sheepgpt instead of the embedded /chat instance.
function fragmentFromPage(html) {
const $ = cheerio.load(html);
const body = $("body");
const templateAttrs = Object.fromEntries(
Object.entries(body[0]?.attribs || {}).filter(([name]) =>
name.startsWith("data-bliss-template"),
),
return (
$.html() +
`<head id="head" hx-oob-swap="beforeend">${headInjection || ""}</head>`
);
if (Object.keys(templateAttrs).length) {
for (const child of body.children().toArray()) {
Object.assign(child.attribs, templateAttrs);
}
}
return body.html();
}
// Single entry point for turning rendered HTML into a response body. A full
@ -524,13 +415,13 @@ function fragmentFromPage(html) {
// carrying provenance becomes a decorated fragment; anything else passes through.
function decorate(html, { headInjection, source, fragment } = {}) {
const lower = html.toLowerCase();
const isFullDoc = lower.startsWith("<html>") || lower.startsWith("<!doctype");
if (!fragment) {
const isFullDoc =
lower.startsWith("<html>") || lower.startsWith("<!doctype");
if (isFullDoc || !fragment) {
return decoratePage(html, { headInjection, source });
}
const fragmentHtml = isFullDoc ? fragmentFromPage(html) : html;
if (source) return decorateFragment(fragmentHtml, { headInjection, source });
return fragmentHtml;
if (source) return decorateFragment(html, { headInjection, source });
return html;
}
// One prefix-bound route() helper per structure, shared by every render site.
@ -542,48 +433,12 @@ function renderTemplate(structureId, templateName, context) {
return model.getTemplater(structureId).render(templateName, context);
}
// Re-render one saved template instance with the JSON-safe values captured on
// that instance when the page was first produced. This is deliberately a
// platform endpoint rather than a user route: editing a template must not
// replay a route handler (and its side effects) just to update the markup.
app.post("/_bliss/render-template", (req, res) => {
const structureId = String(req.body?.structureId ?? "");
const templateId = String(req.body?.templateId ?? "");
const template = model.getTemplate(templateId);
if (!template || String(template.structure_id) !== structureId) {
return res.status(404).send("Template not found");
}
const structure = model.getStructure(structureId);
const savedContext = model.getInspectableTemplateContext(
req.body?.contextId,
templateId,
);
if (!savedContext)
return res.status(410).send("Template context expired; refresh the page");
const context = { ...savedContext };
context.route = makeRoute(structure);
return res
.type("html")
.send(renderTemplate(structureId, template.name, context));
});
// Provenance for a rendered response: GET routes are re-embeddable, so they
// carry the copy-embed snippet; other verbs don't.
function sourceFor(route, req, templateName = null) {
const structure = model.getStructure(route.structure_id);
const template = templateName
? model.getTemplateByName(route.structure_id, templateName)
: null;
function sourceFor(route, req) {
return {
structureId: route.structure_id,
structureName: structure.name,
routeId: route.id,
verb: route.verb,
path: route.path,
requestUrl: req.originalUrl,
templateId: template?.id,
templateName: template?.name,
copyUrl: route.verb === "GET" ? embedHTML(req.originalUrl) : null,
};
}
@ -622,11 +477,7 @@ function sidebarStuff(structId) {
}
app.get("/workshop/:structure_id", (req, res) => {
return renderWorkshop(
res,
"workshop/editor",
sidebarStuff(req.params.structure_id),
);
return renderWorkshop(res, "workshop/editor", sidebarStuff(req.params.structure_id));
});
app.post("/workshop/:structure_id/clone", (req, res) => {
@ -711,11 +562,8 @@ app.put("/workshop/:structure_id/route/:route_id", (req, res) => {
model.updateRoute({ ...route, ...req.body });
if (req.body.scaffold_page) {
// TODO: make this send over scaffold page id too someday
const latestPage = model.getLatestScaffoldPage(req.params.route_id);
model.updateScaffoldPage({
...latestPage,
content: req.body.scaffold_page,
});
const latestPage = model.getLatestScaffoldPage(req.params.route_id)
model.updateScaffoldPage({ ...latestPage, content: req.body.scaffold_page })
}
buildRoutes();
return res.send("good");
@ -824,12 +672,9 @@ app.get("/workshop/:structure_id/template/:template_id/preview", (req, res) => {
context.it.route = makeRoute(struct);
return res.send(
decorate(
renderTemplate(req.params.structure_id, template.name, context.it),
{
decorate(renderTemplate(req.params.structure_id, template.name, context.it), {
headInjection: struct.head_injection,
},
),
}),
);
});
@ -838,16 +683,16 @@ app.get("/workshop/:structure_id/route/:id", (req, res) => {
let routePrefix = model.getStructure(req.params.structure_id).route_prefix;
let previewUrl = null;
if (route["verb"] == "GET") {
if (route["verb"] == "GET" ) {
previewUrl = routePrefix
? path.join(routePrefix || "", route.path)
: route.path;
} else {
}
else {
previewUrl = `/workshop/${req.params.structure_id}/route/${req.params.id}/preview`;
}
const template =
route["verb"] == "GET" ? "workshop/route" : "workshop/cud_route";
const template = route["verb"] == "GET" ? "workshop/route" : "workshop/cud_route";
return renderWorkshop(res, template, {
route: route,
@ -898,13 +743,15 @@ app.post("/workshop/:structure_id/files", async (req, res) => {
const targetFile = req.files.file;
try {
const uploadedFile = await saveFile(structure_id, req, targetFile);
const uploadedFile = await saveFile(structure_id, req, targetFile)
return res.send(eta.render("workshop/file_detail", { file: uploadedFile }));
} catch (e) {
console.log(e);
}
catch (e) {
console.log(e)
res.status(500);
return res.send(e);
}
});
function prefixUrlWithHost(req, path) {
@ -999,11 +846,6 @@ app.all("*", async (req, res) => {
req.params = routeMatch.params;
const route = routeMatch.route;
// Minimal, handcrafted view of the logged-in Bliss user for user routes.
// Deliberately NOT the raw session/user object — just {id, username} —
// so structures can greet whoever is logged in without exposing internals.
req.currentUser = currentUserFor(req);
const structure = model.getStructure(route.structure_id);
try {
@ -1020,18 +862,14 @@ app.all("*", async (req, res) => {
res.send(
decorate(renderTemplate(route.structure_id, template, context), {
headInjection: structure.head_injection,
source: sourceFor(route, req, template),
source: sourceFor(route, req),
fragment: req.headers["hx-request"],
}),
);
};
const handlerScript = new vm.Script(route.handler, {
filename: `handler_${route.id}.js`,
});
const handlerScript = new vm.Script(route.handler, { filename: `handler_${route.id}.js` });
// Prepare the execution code that invokes `handler(req, res)` and awaits it
const executionScript = new vm.Script(`handler(req, res); `, {
filename: `execution_${route.id}.js`,
});
const executionScript = new vm.Script(`handler(req, res); `, { filename: `execution_${route.id}.js` });
await handlerScript.runInContext(context);
const result = await executionScript.runInContext(context);
} catch (e) {

View file

@ -1,392 +1,120 @@
(function () {
if (window.__blissInspectorLoaded) return;
window.__blissInspectorLoaded = true;
document.addEventListener("DOMContentLoaded", function () {
// Create the magnifying glass emoji button
const magnifyingGlass = document.createElement("div");
magnifyingGlass.innerHTML = "🔍";
magnifyingGlass.className =
"fixed bottom-2 right-2 cursor-pointer text-2xl z-50";
document.body.appendChild(magnifyingGlass);
const ROUTE_SELECTOR = "[data-bliss-structure-id][data-bliss-route-id]";
const TEMPLATE_SELECTOR = "[data-bliss-template-id]";
let root = null;
let open = false;
let highlighted = false;
function visibleElements(selector) {
return [...document.querySelectorAll(selector)].filter(
(element) => !element.closest("[data-bliss-ui]"),
// Function to highlight elements
function highlightElements() {
const modal = document.createElement("div");
modal.id = "inspector-modal";
document.body.appendChild(modal);
const elements = document.querySelectorAll("[data-bliss-route]");
elements.forEach((el) => {
if (!el.classList.contains("highlighted")) {
el.classList.add(
"border-2",
"border-yellow-500",
"highlighted",
"p-2",
);
}
let controls = document.createElement("div");
controls.className =
"flex absolute gap-1 -bottom-3 right-0 inspector-controls";
function routeElements() {
return visibleElements(ROUTE_SELECTOR);
}
// Create the edit icon
const editIcon = document.createElement("a");
editIcon.href = el.getAttribute("data-bliss-route");
editIcon.innerHTML = "✏️";
editIcon.className =
"text-lg bg-white rounded-full p-1 shadow edit-icon";
controls.appendChild(editIcon);
function templateElements() {
return visibleElements(TEMPLATE_SELECTOR);
}
function templatesFor(element) {
try {
const templates = JSON.parse(element.dataset.blissTemplates || "[]");
if (templates.length) return templates;
} catch (_) {}
return element.dataset.blissTemplateId
? [
{
id: element.dataset.blissTemplateId,
name: element.dataset.blissTemplateName,
},
]
: [];
}
function contextIdFor(element, templateId) {
try {
const ids = JSON.parse(element.dataset.blissTemplateContextIds || "{}");
if (Object.hasOwn(ids, String(templateId)))
return ids[String(templateId)];
} catch (_) {}
return element.dataset.blissTemplateContextId || null;
}
function inventory() {
const items = [];
for (const owner of routeElements()) {
const templates = templateElements().filter(
(element) => element.closest(ROUTE_SELECTOR) === owner,
);
if (!templates.length) templates.push(null);
for (const templateElement of templates) {
const renderedTemplates = templateElement
? templatesFor(templateElement)
: [null];
for (const template of renderedTemplates) {
items.push({
structureId: owner.dataset.blissStructureId,
structureName: owner.dataset.blissStructureName,
routeId: owner.dataset.blissRouteId,
routeName: owner.dataset.blissRouteName,
templateId: template?.id || null,
templateName: template?.name || null,
// Create the copy icon
if (el.getAttribute("data-bliss-copy")) {
const copyIcon = document.createElement("button");
copyIcon.innerHTML = "📋";
copyIcon.className =
"text-lg bg-white rounded-full p-1 shadow copy-icon";
controls.appendChild(copyIcon);
copyIcon.addEventListener("click", () => {
navigator.clipboard
.writeText(el.getAttribute("data-bliss-copy"))
.then(() => {
copyIcon.innerHTML = "✅";
})
.catch((err) => {
copyIcon.innerHTML = "❌";
});
}
}
}
return items;
}
function publishInventory() {
document.dispatchEvent(
new CustomEvent("bliss:inventory", { detail: inventory() }),
);
}
function matches(element, { kind, id }) {
if (kind === "template") {
return templatesFor(element).some(
(template) => String(template.id) === String(id),
);
}
const field = {
structure: "blissStructureId",
route: "blissRouteId",
}[kind];
return field && element.dataset[field] === String(id);
}
function highlight(detail) {
const candidates =
detail.kind === "template" ? templateElements() : routeElements();
for (const element of candidates.filter((el) => matches(el, detail))) {
element.classList.toggle("bliss-source-highlight", detail.on);
}
}
function nodeKey(node) {
if (node.nodeType !== Node.ELEMENT_NODE) return null;
if (node.id) return `id:${node.id}`;
if (node.dataset.messageId) return `message:${node.dataset.messageId}`;
if (node.dataset.blissRouteId) {
return `route:${node.dataset.blissRouteId}:${node.dataset.blissRequestUrl || ""}`;
}
return null;
}
function sameNodeType(left, right) {
return (
left.nodeType === right.nodeType &&
(left.nodeType !== Node.ELEMENT_NODE || left.tagName === right.tagName)
);
}
function syncAttributes(element, next) {
for (const attribute of [...element.attributes]) {
if (!next.hasAttribute(attribute.name))
element.removeAttribute(attribute.name);
}
for (const attribute of [...next.attributes]) {
if (element.getAttribute(attribute.name) !== attribute.value) {
element.setAttribute(attribute.name, attribute.value);
}
}
}
// Live regions can contain data that arrived after the template was first
// rendered (a WebSocket chat message is the important case). Those nodes
// are not present in the saved render context, so retain them during a
// template patch instead of deleting them with the old tree.
function keepLiveNode(node) {
return (
node.nodeType === Node.ELEMENT_NODE &&
(node.matches("[data-bliss-ui]") ||
node.hasAttribute("data-message-id") ||
node.hasAttribute("data-bliss-route-id"))
);
}
function morphNode(current, next) {
if (!sameNodeType(current, next)) {
current.replaceWith(next.cloneNode(true));
return;
}
if (current.nodeType !== Node.ELEMENT_NODE) {
if (current.nodeValue !== next.nodeValue)
current.nodeValue = next.nodeValue;
return;
}
syncAttributes(current, next);
const currentChildren = [...current.childNodes];
const keyed = new Map();
for (const child of currentChildren) {
const key = nodeKey(child);
if (key && !keyed.has(key)) keyed.set(key, child);
}
const used = new Set();
let cursor = current.firstChild;
for (const nextChild of [...next.childNodes]) {
const key = nodeKey(nextChild);
let child = key ? keyed.get(key) : null;
if (child && used.has(child)) child = null;
if (!child) {
child = currentChildren.find(
(candidate) =>
!used.has(candidate) &&
!nodeKey(candidate) &&
sameNodeType(candidate, nextChild),
);
}
if (!child) child = nextChild.cloneNode(false);
if (child !== cursor) current.insertBefore(child, cursor);
used.add(child);
morphNode(child, nextChild);
cursor = child.nextSibling;
}
for (const child of currentChildren) {
if (!used.has(child) && child.isConnected && !keepLiveNode(child))
child.remove();
}
}
async function replaceBody(html, { morph = false } = {}) {
const next = new DOMParser().parseFromString(html, "text/html");
if (morph) {
morphNode(document.body, next.body);
htmx.process(document.body);
return;
}
const ui = [...document.body.querySelectorAll(":scope > [data-bliss-ui]")];
for (const attribute of [...document.body.attributes]) {
document.body.removeAttribute(attribute.name);
}
for (const attribute of [...next.body.attributes]) {
document.body.setAttribute(attribute.name, attribute.value);
}
document.body.innerHTML = next.body.innerHTML;
for (const node of ui) document.body.appendChild(node);
htmx.process(document.body);
}
async function refreshElement(element) {
if (element.dataset.blissMethod !== "GET") return;
const url = element.dataset.blissRequestUrl;
if (!url) return;
if (element === document.body) {
// A page root must be fetched as a document. Asking for an HTMX
// fragment here used to parse an empty <body> and erase the page.
const response = await fetch(url);
if (!response.ok)
throw new Error(`GET ${url} returned ${response.status}`);
await replaceBody(await response.text());
return;
}
await htmx.ajax("GET", url, { target: element, swap: "outerHTML" });
}
function outermost(elements, selector) {
const set = new Set(elements);
return elements.filter((element) => {
let parent = element.parentElement;
while (parent) {
if (set.has(parent) && parent.matches(selector)) return false;
parent = parent.parentElement;
}
return true;
});
}
async function refreshTemplateElement(
element,
templateId,
previousRoots = [],
) {
const response = await fetch("/_bliss/render-template", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
structureId: element.closest(ROUTE_SELECTOR)?.dataset.blissStructureId,
templateId,
contextId: contextIdFor(element, templateId),
}),
// Create the clone icon
const cloneIcon = document.createElement("div");
cloneIcon.innerHTML = "👯‍♀️";
cloneIcon.className =
"text-lg bg-white rounded-full cursor-pointer p-1 shadow clone-icon";
cloneIcon.setAttribute("hx-get", el.getAttribute("data-bliss-clone"));
cloneIcon.setAttribute("hx-target", "#inspector-modal");
controls.setAttribute(
"_",
`on mouseover set my.style.zIndex to 10000
on mouseout set my.style.zIndex to "initial"
`,
);
controls.appendChild(cloneIcon);
el.appendChild(controls);
htmx.process(controls);
_hyperscript.processNode(controls);
}
});
if (!response.ok) {
throw new Error(`Template ${templateId} returned ${response.status}`);
}
const html = await response.text();
// The render-template endpoint re-stamps template provenance but not the
// route/structure provenance (that comes from the route pipeline). Carry it
// over from the element being replaced so the inspector overlay and tree
// stay complete after a live template edit, instead of losing the element's
// route/copy/clone identity.
const provenance = [...element.attributes]
.filter((attribute) => attribute.name.startsWith("data-bliss-"))
.map((attribute) => ({ name: attribute.name, value: attribute.value }));
const carry = (target) => {
for (const { name, value } of provenance) {
if (!target.hasAttribute(name)) target.setAttribute(name, value);
}
};
if (element === document.body) {
await replaceBody(html, { morph: true });
carry(document.body);
return;
}
const fragment = document.createElement("template");
fragment.innerHTML = html;
// A template may legally have several top-level nodes. They share one
// captured context id, so replace that one render as a group instead of
// rendering the same template once per root and duplicating its output.
for (const root of previousRoots) {
if (root !== element && root.isConnected) root.remove();
}
for (const root of fragment.content.children) carry(root);
element.replaceWith(fragment.content);
htmx.process(document.body);
}
function templateRefreshGroups(templateId) {
const elements = outermost(
templateElements().filter((element) =>
matches(element, { kind: "template", id: templateId }),
),
TEMPLATE_SELECTOR,
// Function to remove highlights
function removeHighlights() {
const elements = document.querySelectorAll(
"[data-bliss-route].highlighted",
);
const groups = new Map();
for (const element of elements) {
const contextId = contextIdFor(element, templateId);
const key = contextId || `element:${Math.random()}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(element);
}
return [...groups.values()];
}
async function refreshSources({ kind, id, method }) {
if (method && method !== "GET") return;
const elements =
kind === "template"
? templateRefreshGroups(id)
: outermost(
routeElements().filter((element) => matches(element, { kind, id })),
ROUTE_SELECTOR,
elements.forEach((el) => {
el.classList.remove(
"border-2",
"border-yellow-500",
"relative",
"highlighted",
"p-2",
);
const refresh =
kind === "template"
? (group) => refreshTemplateElement(group[0], id, group)
: refreshElement;
const settled = await Promise.allSettled(elements.map(refresh));
const failure = settled.find((result) => result.status === "rejected");
if (failure) console.error("Bliss live refresh failed", failure.reason);
publishInventory();
el.querySelectorAll(".inspector-controls").forEach((el) => el.remove());
});
}
function show() {
if (open) return;
open = true;
document.documentElement.classList.add("bliss-inspector-open");
root = document.createElement("div");
root.id = "bliss-inspector-root";
root.dataset.blissUi = "";
root.setAttribute("hx-get", "/_bliss/inspector");
root.setAttribute("hx-trigger", "load");
root.setAttribute("hx-swap", "innerHTML");
document.body.appendChild(root);
htmx.process(root);
magnifyingGlass.addEventListener("click", function () {
if (!highlighted) {
highlightElements();
magnifyingGlass.innerHTML = "❌";
} else {
removeHighlights();
magnifyingGlass.innerHTML = "🔍";
}
function hide() {
open = false;
document.documentElement.classList.remove("bliss-inspector-open");
root?.remove();
root = null;
for (const element of [...routeElements(), ...templateElements()]) {
element.classList.remove("bliss-source-highlight");
}
}
document.addEventListener("DOMContentLoaded", () => {
const style = document.createElement("style");
style.dataset.blissUi = "";
style.textContent = `
html.bliss-inspector-open body > :not([data-bliss-ui]) {
transform: translateX(min(28rem, 90vw));
max-width: calc(100vw - min(28rem, 90vw));
}
[data-bliss-structure-id], [data-bliss-template-id] {
transition: outline-color 80ms linear, background-color 80ms linear;
}
html.bliss-inspector-open [data-bliss-structure-id],
html.bliss-inspector-open [data-bliss-template-id] {
outline: 1px dashed rgba(255, 0, 168, .5);
outline-offset: -1px;
}
.bliss-source-highlight {
outline: 4px solid #ff00a8 !important;
outline-offset: 3px !important;
background-color: rgba(255, 0, 168, .08) !important;
}
`;
document.head.appendChild(style);
const trigger = document.createElement("button");
trigger.type = "button";
trigger.textContent = "🔍";
trigger.title = "Open Bliss live editor";
trigger.dataset.blissUi = "";
trigger.style.cssText =
"position:fixed;right:.5rem;bottom:.5rem;z-index:2147483647;border:0;background:white;border-radius:999px;padding:.45rem;font-size:1.25rem;cursor:pointer;box-shadow:0 2px 8px #0004";
trigger.onclick = () => (open ? hide() : show());
document.body.appendChild(trigger);
highlighted = !highlighted;
});
document.addEventListener("bliss:highlight", (event) =>
highlight(event.detail),
);
document.addEventListener("bliss:source-saved", (event) =>
refreshSources(event.detail),
);
document.addEventListener("bliss:inventory-request", publishInventory);
document.addEventListener("bliss:inspector-close", hide);
document.addEventListener("htmx:afterSwap", () => {
if (open) publishInventory();
});
})();
// Observe the document for changes
// const observer = new MutationObserver(function (mutations) {
// if (highlighted) {
// highlightElements();
// }
// });
// // Configure the observer
// observer.observe(document.body, {
// childList: true,
// subtree: true,
// });
});

View file

@ -1,89 +0,0 @@
"""Browser regression test for Bliss template saves inside an hx-get child page.
Run with:
.venv/bin/python tests/test_inspector_sheepgpt.py
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from urllib.parse import urlparse
from playwright.sync_api import Page, Request, sync_playwright
BASE_URL = "https://brb.city"
SHEEPGPT_URL = f"{BASE_URL}/sheepgpt/"
MESSAGE_RAW_TEMPLATE_ID = "28"
@dataclass
class NetworkTrace:
requests: list[tuple[str, str]] = field(default_factory=list)
def record(self, request: Request) -> None:
self.requests.append((request.method, urlparse(request.url).path))
def after(self, start: int) -> list[tuple[str, str]]:
return self.requests[start:]
class BlissInspector:
"""Small page-object API for the inspector's normal user workflow."""
def __init__(self, page: Page) -> None:
self.page = page
def open_slideout(self) -> None:
self.page.get_by_role("button", name="🔍").click()
self.page.locator("#bliss-live-editor").wait_for()
def open_template(self, name: str, template_id: str) -> None:
self.page.get_by_role("button", name=re.compile(rf"^{re.escape(name)}(?: |$)")).click()
self.page.locator(
f'[data-bliss-artifact-editor][data-kind="template"][data-id="{template_id}"]'
).wait_for()
def save_template(self) -> None:
self.page.locator("[data-bliss-artifact-editor] [data-save]").click()
def main() -> None:
with sync_playwright() as playwright:
browser = playwright.chromium.launch()
page = browser.new_page()
trace = NetworkTrace()
page.on("request", trace.record)
page.goto(SHEEPGPT_URL, wait_until="networkidle")
inspector = BlissInspector(page)
inspector.open_slideout()
inspector.open_template("message-raw", MESSAGE_RAW_TEMPLATE_ID)
chat_before = page.locator("#chat").element_handle()
assert chat_before is not None, "the embedded chat did not render"
start = len(trace.requests)
with page.expect_response(re.compile(r"/_bliss/render-template$")) as rendered:
inspector.save_template()
render_response = rendered.value
print(f"template render response: {render_response.status} {render_response.url}")
print(f"template render body: {render_response.request.post_data}")
print("requests after message-raw save:")
for method, path in trace.after(start):
print(f" {method} {path}")
assert render_response.status == 200, render_response.text()
page.wait_for_timeout(200)
requests = trace.after(start)
# Saving a child template must never replay the outer sheepgpt route.
assert ("GET", "/sheepgpt/") not in requests, requests
# The embedded chat itself must stay mounted; only matching template
# instances should be patched.
assert page.locator("#chat").element_handle() == chat_before
assert page.locator("#bliss-live-editor").is_visible()
browser.close()
if __name__ == "__main__":
main()