31 KiB
Chat Rearchitecture — "AIM 2005" edition
Living design doc and build log. Section 1–8 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)
- 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.
- Reliable, quiet presence. Login/logout ("signed in / signed out") notices are unreliable and noisy today. Make them consistent and calm.
- 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.
- 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.
- Clean rearchitecture with stricter template separation — pieces that are well-isolated and independently embeddable.
- Event-driven composition like the
sheepgptdemo. 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). - 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.
- When a new message arrives, the WebSocket should just tell clients to
hx-getthat message's route and render it — the socket is a notification bus, not a renderer. (First render mayhx-geteverything; that's fine.) - 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
joinevent callscreateAlias(username)— a brand-new row every time, stored inreq.session.alias_id. There is no localStorage, and thesecret auto login formtemplate 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_idis in session at subscribe time. Later your session'salias_idchanges (see above), sosendPushNotifications(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.
broadcastSigningfires on every socketopen/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. getMessageByIdis broken SQL (message.idshould bemessages.message_id, missingmessage_id/created_atselects) — so the "render a single message by route" primitive (goal #8) doesn't actually work.- Text too big.
chatusestext-xl; message area isbg-lime-400; message bubbles arebg-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 onechattemplate, 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, 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 (~12–13px). 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 carrydetail.zoneand listeners filter. (Leaning towarddetail.zonefiltering + a barechat:send/chat:messagename, 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 existingsay bridge/ sheepfriendchat:say). A hiddenws-sendbridge catches it and pushes it over the socket. → We keep achat:sayalias 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):
- Client dispatches
chat:send→ hiddenws-sendform → socket. - Server WS handler: dedup, persist via
createMessage, getmessage_id. - Server broadcasts to all room clients a tiny fragment that is just an
hx-getof/message/:id(out-of-band swap appending into the transcript):
The socket never ships message markup — only a pointer. The<div id="transcript-<zone>" hx-swap-oob="beforeend"> <div hx-get="/aim/message/ID?zone=ZONE" hx-trigger="load" hx-swap="outerHTML"></div> </div>/message/:idroute is the single source of truth for how a message looks. /message/:idrenders the message (unescaped content!) and, on load, dispatcheschat:messagefor 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):
- localStorage
aim:handle(what you last called yourself), andaim:client_id(a stable per-browser UUID minted once). - Logged-in Bliss user's username (default when no saved handle). Requires exposing the current user to the sandbox — see Section 6.
"guest"fallback.
Rules:
- One stable alias per handle, via
getOrCreateAlias(username)(already exists) — nevercreateAliason 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 churnyalias_id. Fixes self-notify. - Auto-rejoin on load: if
aim:handleexists, silently register it into the session (no visible "join" form unless the user is truly new and not logged in). The emptysecret auto login formtemplate gets a real implementation. - Changing your name writes
aim:handleand updates the alias; presence and push keep working because they key onclient_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 pushendpoint. New table (or reusealiases.push_subscriptionbut add apush_substable):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'sclient_id.** The sender'sclient_idtravels with thechat: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 renderName: 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/.... stylesis 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
(
messagetemplate) 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 (dispatcheschat:send).GET /frozen— static range snapshot (?before=&after=or?since=).GET /message/:id— one message (fixesgetMessageById).WS /room— socket bus:send(→persist→broadcast hx-get pointer),presence,catchup. Dedup named/bot messages.POST /push-subscribe— upsertpush_subsby endpoint, keyed byclient_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
aimstructure, attach/clonechatdb. ← you are here - Phase 1 — Data & single message. Fix
getMessageById, add range + push-sub helpers +push_subsmigration. ShipGET /message/:id+messagetemplate 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 /roombroadcasting onlymsg-pointerhx-gets. Confirm new messages appear by the client hx-getting/message/:id. Emitchat:messageon arrival. - Phase 3 — Compose + event bus.
GET /compose+ws-bridgelistening forchat:send(and legacychat:say). Confirm compose-only and live-only work separately, and together via sharedzone. - Phase 4 — Identity.
identity-boot: localStorage handle + client_id, auto-rejoin, default to logged-in Bliss user.POST /rename. (Depends on thereq.currentUserdecision.) - 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.frozenview. - Phase 8 — Compose demo. A page that nests
/live+/compose+ maybe the sheep, proving the parts compose (anaimgptanalog tosheepgpt).
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
#18so production/chatstays up. - Fully inventoried
chat#18 (routes/templates/db) and thesheepgpt#42 +sheepfriend#26 event pattern. Root-caused all four reported bugs (forgets-you, self-notify push, noisy presence, + found brokengetMessageById). Written up in §2. - No standalone "bliss MCP" server exists in this session; driving Bliss via the
blissCLI per project convention. - Next: resolve Open Questions (esp. shared vs. fresh DB, and the
req.currentUserindex.js change), then Phase 1.
- Instance under edit: brb.city (live; CLI sticky target). Building a new
structure rather than mutating
-
2026-08-06 — Decisions locked + scaffold built.
- Decisions: new
aimstructure, fresh DB, minimal handcraftedreq.currentUser({id, username}) in index.js (see §11). - Created structure
aim= id 44, prefix/aim, dbchat= 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),getMessageByIdfixed, range/catchup helpers, push-sub upsert keyed by endpoint +subsExcludingClient. Verified alias stability + verbatim HTML storage via REPL, then cleared test rows. - Next:
styles+messagetemplate +GET /message/:id(single-message primitive).
- Decisions: new
-
2026-08-06 — Phases 1–7 built & backend verified on brb.city.
- All 16 templates + 10 routes written (ids in the maps above). Structure
aim#44 is live athttps://brb.city/aim/. - Design pivot (recorded so §4–6 match reality): identity is fully
client-side. localStorage holds
aim:handle+aim:client_id(a stable per-browser UUID). The socket carries them on anidentifyevent (presence) and eachsend(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 stableclient_id; fan-out issubsExcludingClient(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/departin the WS handler.) - Broken single-message →
getMessageByIdrewritten with the correct join; it's now the backbone of the socket-as-pointer-bus design.
- Forgets you → localStorage handle + one stable alias per handle
(
- Socket-as-bus confirmed live via a Node
wsclient:identify→ presence-line broadcast;send→ persisted (message #4) + amsg-pointerbroadcast 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/1renders 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 intranscript. req.currentUseradded (minimal{id, username}) in localindex.js+getUserByIdindb.js(syntax-checked). ⚠️ This repo is the local copy; brb.city runs from/home/nodejs/bliss2on 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.
- All 16 templates + 10 routes written (ids in the maps above). Structure
Verified ✅ vs. remaining ⏳
-
✅ Fresh schema + library (alias stability, verbatim HTML, range/catchup/push helpers)
-
✅
GET /message/:idsingle-message primitive (unescaped content) -
✅
WS /room: identify→presence, send→persist→pointer broadcast, catchup -
✅ Full page,
/live,/compose,/frozenrender 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:messageemit 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.jsto brb.city + restart (pm2foo) 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-1237in~/.cache/ms-playwright). - Sending was broken — real bug, now fixed. Eta's
<%= %>HTML-escapes, sovar dflt = <%= JSON.stringify('Guest') %>;rendered asvar dflt = "Guest";inside the<script>→ "Unexpected token '&'" → the entirews-bridgescript threw →window.aimIdundefined → nochat:sendlistener → 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:
_hyperscripthas no ternary (?:) — the screen-name spanset my innerHTML to (window.aimId ? … : …)threw. Replaced with a plain-JS.aim-screennamepainter inidentity-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-pointerbroadcast →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.currentUserchange is deployed to brb.city.
- Got Playwright MCP working (it wanted chromium build 1237; symlinked the
installed
-
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 withuser-scalable=no+interactive-widget=resizes-content, theme-color, apple PWA metas,touch-action:manipulationto 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 +
.icovia ImageMagick, uploaded to structure 44 (servedbrb.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-contentmakes 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 avisualViewportscript — verified it shrank 844→544px — then removed it per the clean-CSS decision.)
- Root cause of "text too small on mobile": no
-
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>. Toolbarattachcontrol. - Upload path = disk.
POST /upload→require('files').saveFile→public/<sid>/<name>, served at/<sid>/<name>; returns same-origin path. Found + fixed a framework bug:saveFiledidpath.join(__dirname,"public", structureId)with a numeric id (only worked from the workshop route which passes a string) →String()coercion inindex.js(reproduced before/after). ⚠️ Needs the index.js deploy to brb.city for uploads to persist live (same deploy asreq.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, passingclassWorkerURLforces a module worker, but the UMD worker chunk is classic (importScripts) → "failed to import ffmpeg-core.js". Fix: loadffmpeg.jssame-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 astoBlobURLblobs. - iOS caveat: ffmpeg.wasm is memory-bound in WKWebView; short clips fine, very large/long ones may fail. Documented, acceptable for the playground.
- 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
How to embed the pieces (the payoff)
<!-- 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)
- New structure
aim(prefix/aim). ✅ Production/chat#18 stays live and untouched; switch over later once proven. - Fresh DB. ✅ Start clean — no message/alias history carried over. Schema
built from scratch for this design (messages, aliases,
push_subs). - Expose a minimal, handcrafted
req.currentUserinindex.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 mainuserstable viamodel.getUser. Inject it in theapp.all("*")handler (and WS dispatch). Requires a restart of the live brb.city process (pm2foo). - Event scoping: single event name +
detail.zonefilter (lets cross-zone bridges like the sheep listen broadly). Confirm ergonomics during Phase 3. - PWA/manifest/service-worker: keep existing behavior; only change the push payload and keying (client_id/endpoint).