<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
    <channel>
      <title>Hísilómë</title>
      <link>https://hisilo.me</link>
      <description>Self-taught web developer and Dev[Sec]Ops engineer with a passion for internet research. Dedicated Nix evangelist and cybersecurity enthusiast.</description>
      <generator>Zola</generator>
      <language>en</language>
      <atom:link href="https://hisilo.me/rss.xml" rel="self" type="application/rss+xml"/>
      <lastBuildDate>Sun, 30 Aug 2026 00:00:00 +0000</lastBuildDate>
      <item>
          <title>A Radio That Survives Navigation, Without JavaScript</title>
          <pubDate>Sun, 30 Aug 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://hisilo.me/radio-without-javascript/</link>
          <guid>https://hisilo.me/radio-without-javascript/</guid>
          <description xml:base="https://hisilo.me/radio-without-javascript/">&lt;p&gt;There is a radio playing on this page. Click a link — any link — and it keeps playing. No script runs to make that happen.&lt;&#x2F;p&gt;
&lt;p&gt;This is the part everyone reaches for a single-page app to solve. You don&#x27;t need one.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-problem&quot;&gt;The problem&lt;&#x2F;h2&gt;
&lt;p&gt;A page load destroys the document. The &lt;code&gt;&amp;lt;audio&amp;gt;&lt;&#x2F;code&gt; element goes with it, along with its buffer and its open connection to the stream. Click a link, the music stops. That is why every web radio you have used is either a single-page app or a popup window from 2003.&lt;&#x2F;p&gt;
&lt;p&gt;The usual fix is to stop navigating: intercept every click, fetch the new content, swap it into the DOM, and keep the audio element alive because the document never changed. That is a framework, a router, and a few hundred kilobytes of JavaScript to avoid reloading a page.&lt;&#x2F;p&gt;
&lt;p&gt;The other fix is to not put the audio in the document that navigates.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-backend-is-a-real-radio&quot;&gt;The backend is a real radio&lt;&#x2F;h2&gt;
&lt;p&gt;Worth stating first, because it changes what the frontend has to do.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.liquidsoap.info&#x2F;&quot;&gt;Liquidsoap&lt;&#x2F;a&gt; reads a playlist and encodes it into &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;icecast.org&#x2F;&quot;&gt;Icecast&lt;&#x2F;a&gt;, continuously, whether or not anyone is listening. Icecast fans that one stream out to every listener. The music plays to an empty room at 4am.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;liquidsoap&quot;&gt;radio = playlist(mode=&amp;quot;randomize&amp;quot;, &amp;quot;music&#x2F;&amp;quot;)
radio = amplify(0.5, radio)
radio = mksafe(radio)

output.icecast(
  %mp3,
  host=&amp;quot;127.0.0.1&amp;quot;, port=8000,
  mount=&amp;quot;&#x2F;stream.mp3&amp;quot;,
  radio
)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Nothing the browser does can pause it, skip it, or rewind it. There is no message a client can send that would. Pressing stop only closes your own socket — the broadcast carries on without you, and when you come back you rejoin wherever it has got to. That is what makes it a radio rather than a playlist with a shuffle button.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-player-is-one-html-tag&quot;&gt;The player is one HTML tag&lt;&#x2F;h2&gt;
&lt;pre&gt;&lt;code data-lang=&quot;html&quot;&gt;&amp;lt;audio controls preload=&amp;quot;none&amp;quot;&amp;gt;
  &amp;lt;source src=&amp;quot;&#x2F;stream.mp3&amp;quot; type=&amp;quot;audio&#x2F;mpeg&amp;quot;&amp;gt;
&amp;lt;&#x2F;audio&amp;gt;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That is the entire player. &lt;code&gt;preload=&quot;none&quot;&lt;&#x2F;code&gt; is doing real work: without it every page load opens a stream connection for every visitor, whether or not they ever press play.&lt;&#x2F;p&gt;
&lt;p&gt;No custom controls, no volume slider, no seek bar worth showing — seeking is meaningless on a live stream, so the timeline gets hidden in CSS and the browser&#x27;s own play button does the rest.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;keeping-it-alive-a-frame&quot;&gt;Keeping it alive: a frame&lt;&#x2F;h2&gt;
&lt;p&gt;The shell document holds the chrome and the player. The blog loads into a frame in the middle. Only the frame ever navigates.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;html&quot;&gt;&amp;lt;body class=&amp;quot;shell-body&amp;quot;&amp;gt;
  &amp;lt;header class=&amp;quot;topbar&amp;quot;&amp;gt;
    &amp;lt;a href=&amp;quot;&#x2F;&amp;quot; target=&amp;quot;content&amp;quot;&amp;gt;Hísilómë&amp;lt;&#x2F;a&amp;gt;
    &amp;lt;nav&amp;gt;&amp;lt;a href=&amp;quot;&#x2F;tags&#x2F;&amp;quot; target=&amp;quot;content&amp;quot;&amp;gt;.&#x2F;tags&amp;lt;&#x2F;a&amp;gt;&amp;lt;&#x2F;nav&amp;gt;
  &amp;lt;&#x2F;header&amp;gt;

  &amp;lt;iframe class=&amp;quot;shell-content&amp;quot; src=&amp;quot;&#x2F;&amp;quot; name=&amp;quot;content&amp;quot;&amp;gt;&amp;lt;&#x2F;iframe&amp;gt;

  &amp;lt;footer&amp;gt;
    &amp;lt;audio controls preload=&amp;quot;none&amp;quot;&amp;gt;
      &amp;lt;source src=&amp;quot;&#x2F;stream.mp3&amp;quot; type=&amp;quot;audio&#x2F;mpeg&amp;quot;&amp;gt;
    &amp;lt;&#x2F;audio&amp;gt;
  &amp;lt;&#x2F;footer&amp;gt;
&amp;lt;&#x2F;body&amp;gt;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Links in the top bar carry &lt;code&gt;target=&quot;content&quot;&lt;&#x2F;code&gt; so they drive the frame instead of replacing the page. Links &lt;em&gt;inside&lt;&#x2F;em&gt; the frame need nothing at all — a link in a frame navigates its own frame by default. Click through the whole site and the &lt;code&gt;&amp;lt;audio&amp;gt;&lt;&#x2F;code&gt; element is never touched.&lt;&#x2F;p&gt;
&lt;p&gt;This is not a clever new technique. Netscape 2.0 shipped frames in 1996, and persistent chrome around a swapping content area was how the web did this for years before JavaScript could drive navigation. Single-page apps reinvented the pattern mainly to win back the URL bar.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-recursion-problem&quot;&gt;The recursion problem&lt;&#x2F;h2&gt;
&lt;p&gt;If the shell lives at &lt;code&gt;&#x2F;&lt;&#x2F;code&gt; and frames &lt;code&gt;&#x2F;&lt;&#x2F;code&gt;, it frames itself, forever.&lt;&#x2F;p&gt;
&lt;p&gt;You need to answer the same URL two different ways depending on who is asking. Browsers already tell you: every request carries a &lt;code&gt;Sec-Fetch-Dest&lt;&#x2F;code&gt; header, and for a frame it is &lt;code&gt;iframe&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;nginx&quot;&gt;map $http_sec_fetch_dest $root_document {
    iframe  &#x2F;index.html;
    default &#x2F;listen.html;
}

server {
    location = &#x2F; {
        try_files $root_document =404;
    }
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;A person typing your domain gets the shell. The shell&#x27;s own frame asks for the same &lt;code&gt;&#x2F;&lt;&#x2F;code&gt;, gets the index, and the recursion is cut. No cookie, no query parameter, no redirect.&lt;&#x2F;p&gt;
&lt;p&gt;The same header solves the duplicate-chrome problem. The blog template already has a top bar and a footer; inside the frame you want neither, because the shell provides them:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;!--# if expr=&amp;quot;$http_sec_fetch_dest != iframe&amp;quot; --&amp;gt;
&amp;lt;header class=&amp;quot;topbar&amp;quot;&amp;gt;...&amp;lt;&#x2F;header&amp;gt;
&amp;lt;!--# endif --&amp;gt;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Unlike a query parameter, this survives navigation — the header is on &lt;em&gt;every&lt;&#x2F;em&gt; request, including links clicked inside the frame five pages later. Browsers too old to send it just get the top bar twice, which is ugly rather than broken.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;updating-the-page-without-javascript&quot;&gt;Updating the page without JavaScript&lt;&#x2F;h2&gt;
&lt;p&gt;The track title changes every few minutes. Server-Side Includes can put it in the page, but SSI runs at &lt;em&gt;render&lt;&#x2F;em&gt; time — the title would be stale the moment the page finished loading.&lt;&#x2F;p&gt;
&lt;p&gt;A &lt;code&gt;&amp;lt;meta http-equiv=&quot;refresh&quot;&amp;gt;&lt;&#x2F;code&gt; would fix that by reloading the page, which would kill the audio. Unless you scope it to a frame that isn&#x27;t holding the audio.&lt;&#x2F;p&gt;
&lt;p&gt;Liquidsoap writes the title to a file whenever the track changes:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;liquidsoap&quot;&gt;def write_now_playing(m) =
  title = m[&amp;quot;title&amp;quot;]
  write = file.write.stream(atomic=true, temp_dir=&amp;quot;radio&#x2F;state&amp;quot;,
                            &amp;quot;radio&#x2F;state&#x2F;now-playing.txt&amp;quot;)
  write(title)
  write(null)
end

radio.on_metadata(synchronous=true, write_now_playing)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;nginx serves that file — deliberately from outside the site&#x27;s output directory, because the static site generator wipes that on every build:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;nginx&quot;&gt;location = &#x2F;now-playing.txt {
    alias radio&#x2F;state&#x2F;now-playing.txt;
    default_type text&#x2F;plain;
    add_header Cache-Control &amp;quot;no-store, no-cache, must-revalidate&amp;quot;;
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;And a tiny page includes it and reloads itself every twenty seconds:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;meta http-equiv=&amp;quot;refresh&amp;quot; content=&amp;quot;20&amp;quot;&amp;gt;
&amp;lt;p class=&amp;quot;track&amp;quot;&amp;gt;&amp;lt;!--#include virtual=&amp;quot;&#x2F;now-playing.txt&amp;quot; --&amp;gt;&amp;lt;&#x2F;p&amp;gt;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Drop that in an iframe next to the player. The frame reloads; the audio element beside it never does. A self-updating region of a static page, with no script and no polling code.&lt;&#x2F;p&gt;
&lt;p&gt;One footnote for anyone writing this up on their own site: these pages are served with &lt;code&gt;ssi on&lt;&#x2F;code&gt;, so SSI directives in prose get executed rather than displayed. Inside a fenced code block Markdown escapes &lt;code&gt;&amp;lt;&lt;&#x2F;code&gt; to &lt;code&gt;&amp;amp;lt;&lt;&#x2F;code&gt;, which nginx doesn&#x27;t match — that is the only reason the examples above are visible instead of silently running.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-it-costs&quot;&gt;What it costs&lt;&#x2F;h2&gt;
&lt;p&gt;Three real things, stated plainly.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The URL stops following you.&lt;&#x2F;strong&gt; The address bar shows &lt;code&gt;&#x2F;&lt;&#x2F;code&gt; no matter which post you are reading. Deep links still work when someone arrives at one, but copying the address while browsing shares the front page. This is the frameset bargain and it has not changed since 1996.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Stop is not disconnect.&lt;&#x2F;strong&gt; Pausing an &lt;code&gt;&amp;lt;audio&amp;gt;&lt;&#x2F;code&gt; element does not close the socket — the browser keeps buffering, and resuming continues from the buffer, so you drift behind the broadcast. Fixing that requires &lt;code&gt;removeAttribute(&#x27;src&#x27;)&lt;&#x2F;code&gt; and &lt;code&gt;load()&lt;&#x2F;code&gt;, which requires JavaScript. Reloading the page rejoins the live edge, and ordinary browsing already does that for you.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;No default volume.&lt;&#x2F;strong&gt; &lt;code&gt;volume&lt;&#x2F;code&gt; is a scriptable property with no HTML attribute. You cannot set it in markup. The stream is broadcast at half amplitude instead, which is a different thing and everybody&#x27;s problem rather than one listener&#x27;s.&lt;&#x2F;p&gt;
&lt;p&gt;None of these are worth a framework to me.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What&#x27;s next&lt;&#x2F;h2&gt;
&lt;p&gt;The channel above is &lt;strong&gt;cyberia&lt;&#x2F;strong&gt;, after the club in &lt;em&gt;Serial Experiments Lain&lt;&#x2F;em&gt; — which is the whole reason this station exists. It is the only one for now. A second is not much work, mostly another Liquidsoap output and another mount, but it needs a second library worth listening to before it needs any code.&lt;&#x2F;p&gt;
&lt;p&gt;After that, listener count. Icecast already tracks it and exposes it on the admin status endpoint, so it costs nothing but the same self-refreshing frame the title uses. That matters more than it sounds: right now the stream is shared in the technical sense and private in every sense that counts. A number showing that someone else is out there is the difference between a broadcast and a room.&lt;&#x2F;p&gt;
&lt;p&gt;In memory of &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;barrucadu&#x2F;lainonlife&quot;&gt;lainon.life&lt;&#x2F;a&gt;, 2017–2023.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Hello, World</title>
          <pubDate>Wed, 01 Jan 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://hisilo.me/hello-world/</link>
          <guid>https://hisilo.me/hello-world/</guid>
          <description xml:base="https://hisilo.me/hello-world/">&lt;p&gt;This is the first post. Plain HTML, plain CSS, no build step, no JavaScript.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-no-js&quot;&gt;Why no JS&lt;&#x2F;h2&gt;
&lt;p&gt;Every script is an attack surface, a tracking vector, and a reason the page loads slower than it should. None of that is needed to publish text.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;p&amp;gt;this is just html&amp;lt;&#x2F;p&amp;gt;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;New posts are new files in this directory, linked from &lt;a href=&quot;&#x2F;&quot;&gt;the archive&lt;&#x2F;a&gt; and &lt;a href=&quot;&#x2F;rss.xml&quot;&gt;the feed&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
</description>
      </item>
    </channel>
</rss>
