<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:thr="http://purl.org/syndication/thread/1.0">
  <id>https://solovyov.net/</id>
  <title>solovyov.net</title>
  
  <updated>2023-05-17T00:00:00Z</updated>
  
  <author><name>Alexander Solovyov</name></author>
  <link href="https://solovyov.net/" rel="alternate"></link>
  <link href="https://solovyov.net/blog.atom"></link>
  <generator uri="http://github.com/piranha/gostatic/">gostatic</generator>


<entry>
  <id>blog/2023/eventsource-post/</id>
  <author><name>Alexander Solovyov</name></author>
  <title type="html">Server-Sent Events (SSE), but with POST</title>
  <published>2023-05-17T00:00:00Z</published>
  <updated>2023-05-17T00:00:00Z</updated>
  
  <category term="js"></category>
  
  <category term="programming"></category>
  
  <link href="https://solovyov.net/blog/2023/eventsource-post/"></link>
  <content type="html"><![CDATA[
    
    
<p>I really like <a href="http://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events">Server-Sent Events</a>: the protocol is quite simple and
effective, and using it from a browser is easy. Listen for a <code>message</code> event
on an <a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource"><code>EventSource</code></a>, and it just works. <a href="https://github.com/piranha/gostatic/">gostatic</a> hot reload functionality is
<a href="https://github.com/piranha/gostatic/blob/master/hotreload/assets/hotreload.js#L5-L10">built using SSE</a>, and it works very well and takes just a pinch of code.</p>
<p>Let&rsquo;s get to my current usecase though. I&rsquo;m making an AI-powered editor right
now - and streaming response from OpenAI API looks so much better than a really
long wait for a full response. Plus you can abort that response in-flight if you
see it going awry.</p>
<p>Which means I need to send context to a server, right? And the only way to
convey anything to a server using <code>EventSource</code> object is through the URL. There
is no way I can pass enough context through an URL, and I really don&rsquo;t want to
use WebSockets here. It&rsquo;s a totally different beast, and why would I reach for
something that different if I have an almost perfect tool for that job?</p>
<p>So I searched the internets a bit and found <a href="https://rob-blackbourn.medium.com/beyond-eventsource-streaming-fetch-with-readablestream-5765c7de21a1">an implementation of SSE using
fetch</a>. I had to upgrade it a little bit — there was no protocol parsing, but
it&rsquo;s just a few lines of code and it works beautifully.</p>
<p><strong>Except</strong> later on I decided to make that &ldquo;abort&rdquo; button. How do you abort a
<code>fetch</code> request? You create an <a href="http://developer.mozilla.org/en-US/docs/Web/API/AbortController"><code>AbortController</code></a>, then pass it as
<code>fetch(url, {signal: controller.signal})</code>, and then call an <code>.abort()</code>
method. Awkward? Very much so. But at the very least is it working? Not at all!</p>
<p>I mean, yeah, request is aborted. But your promise (the one that <code>fetch</code>
returned) is never rejected (nor resolved, of course). And in Firefox&rsquo; devtools
console you get an error pointing to the line with <code>controller.abort()</code>
call. You can&rsquo;t catch it with a <code>try/catch</code>. <em>Of course!</em> Chrome is even better:
it reports an error on the first line of your HTML. <em>OooOoOoo momma I&rsquo;m a web
developer help me before I killed a man.</em></p>
<p>One option is to reject that promise by myself, but it sounds dirty and does not
get rid of the weird errors in devtools. So I reached to an old friend
<code>XMLHttpRequest</code> and that guy is reliable as ever! Behold the mighty:</p>
<pre style="background-color:#f8f8f8;; overflow-x: auto"><code><span style="display:flex;"><span><span style="color:#a2f;font-weight:bold">function</span> sseevent(message) {
</span></span><span style="display:flex;"><span>  <span style="color:#a2f;font-weight:bold">let</span> type <span style="color:#666">=</span> <span style="color:#b44">&#39;message&#39;</span>, start <span style="color:#666">=</span> <span style="color:#666">0</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#a2f;font-weight:bold">if</span> (message.startsWith(<span style="color:#b44">&#39;event: &#39;</span>)) {
</span></span><span style="display:flex;"><span>    start <span style="color:#666">=</span> message.indexOf(<span style="color:#b44">&#39;\n&#39;</span>);
</span></span><span style="display:flex;"><span>    type <span style="color:#666">=</span> message.slice(<span style="color:#666">7</span>, start);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>  start <span style="color:#666">=</span> message.indexOf(<span style="color:#b44">&#39;: &#39;</span>, start) <span style="color:#666">+</span> <span style="color:#666">2</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#a2f;font-weight:bold">let</span> data <span style="color:#666">=</span> message.slice(start, message.length);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#a2f;font-weight:bold">return</span> <span style="color:#a2f;font-weight:bold">new</span> MessageEvent(type, {data<span style="color:#666">:</span> data})
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a2f;font-weight:bold">export</span> <span style="color:#a2f;font-weight:bold">function</span> XhrSource(url, opts) {
</span></span><span style="display:flex;"><span>  <span style="color:#a2f;font-weight:bold">const</span> eventTarget <span style="color:#666">=</span> <span style="color:#a2f;font-weight:bold">new</span> EventTarget();
</span></span><span style="display:flex;"><span>  <span style="color:#a2f;font-weight:bold">const</span> xhr <span style="color:#666">=</span> <span style="color:#a2f;font-weight:bold">new</span> XMLHttpRequest();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  xhr.open(opts.method <span style="color:#666">||</span> <span style="color:#b44">&#39;GET&#39;</span>, url, <span style="color:#a2f;font-weight:bold">true</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#a2f;font-weight:bold">for</span> (<span style="color:#a2f;font-weight:bold">var</span> k <span style="color:#a2f;font-weight:bold">in</span> opts.headers) {
</span></span><span style="display:flex;"><span>    xhr.setRequestHeader(k, opts.headers[k]);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#a2f;font-weight:bold">var</span> ongoing <span style="color:#666">=</span> <span style="color:#a2f;font-weight:bold">false</span>, start <span style="color:#666">=</span> <span style="color:#666">0</span>;
</span></span><span style="display:flex;"><span>  xhr.onprogress <span style="color:#666">=</span> <span style="color:#a2f;font-weight:bold">function</span>() {
</span></span><span style="display:flex;"><span>    <span style="color:#a2f;font-weight:bold">if</span> (<span style="color:#666">!</span>ongoing) {
</span></span><span style="display:flex;"><span>      <span style="color:#080;font-style:italic">// onloadstart is sync with `xhr.send`, listeners don&#39;t have a chance
</span></span></span><span style="display:flex;"><span><span style="color:#080;font-style:italic"></span>      ongoing <span style="color:#666">=</span> <span style="color:#a2f;font-weight:bold">true</span>;
</span></span><span style="display:flex;"><span>      eventTarget.dispatchEvent(<span style="color:#a2f;font-weight:bold">new</span> Event(<span style="color:#b44">&#39;open&#39;</span>, {
</span></span><span style="display:flex;"><span>        status<span style="color:#666">:</span> xhr.status,
</span></span><span style="display:flex;"><span>        headers<span style="color:#666">:</span> xhr.getAllResponseHeaders(),
</span></span><span style="display:flex;"><span>        url<span style="color:#666">:</span> xhr.responseUrl,
</span></span><span style="display:flex;"><span>      }));
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#a2f;font-weight:bold">var</span> i, chunk;
</span></span><span style="display:flex;"><span>    <span style="color:#a2f;font-weight:bold">while</span> ((i <span style="color:#666">=</span> xhr.responseText.indexOf(<span style="color:#b44">&#39;\n\n&#39;</span>, start)) <span style="color:#666">&gt;=</span> <span style="color:#666">0</span>) {
</span></span><span style="display:flex;"><span>      chunk <span style="color:#666">=</span> xhr.responseText.slice(start, i);
</span></span><span style="display:flex;"><span>      start <span style="color:#666">=</span> i <span style="color:#666">+</span> <span style="color:#666">2</span>;
</span></span><span style="display:flex;"><span>      <span style="color:#a2f;font-weight:bold">if</span> (chunk.length) {
</span></span><span style="display:flex;"><span>        eventTarget.dispatchEvent(sseevent(chunk));
</span></span><span style="display:flex;"><span>      }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  xhr.onloadend <span style="color:#666">=</span> _ =&gt; {
</span></span><span style="display:flex;"><span>    eventTarget.dispatchEvent(<span style="color:#a2f;font-weight:bold">new</span> CloseEvent(<span style="color:#b44">&#39;close&#39;</span>))
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  xhr.timeout <span style="color:#666">=</span> opts.timeout;
</span></span><span style="display:flex;"><span>  xhr.ontimeout <span style="color:#666">=</span> _ =&gt; {
</span></span><span style="display:flex;"><span>    eventTarget.dispatchEvent(<span style="color:#a2f;font-weight:bold">new</span> CloseEvent(<span style="color:#b44">&#39;error&#39;</span>, {reason<span style="color:#666">:</span> <span style="color:#b44">&#39;Network request timed out&#39;</span>}));
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>  xhr.onerror <span style="color:#666">=</span> _ =&gt; {
</span></span><span style="display:flex;"><span>    eventTarget.dispatchEvent(<span style="color:#a2f;font-weight:bold">new</span> CloseEvent(<span style="color:#b44">&#39;error&#39;</span>, {reason<span style="color:#666">:</span> xhr.responseText <span style="color:#666">||</span> <span style="color:#b44">&#39;Network request failed&#39;</span>}));
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>  xhr.onabort <span style="color:#666">=</span> _ =&gt; {
</span></span><span style="display:flex;"><span>    eventTarget.dispatchEvent(<span style="color:#a2f;font-weight:bold">new</span> CloseEvent(<span style="color:#b44">&#39;error&#39;</span>, {reason<span style="color:#666">:</span> <span style="color:#b44">&#39;Network request aborted&#39;</span>}));
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  eventTarget.close <span style="color:#666">=</span> _ =&gt; {
</span></span><span style="display:flex;"><span>    xhr.abort();
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  xhr.send(opts.body);
</span></span><span style="display:flex;"><span>  <span style="color:#a2f;font-weight:bold">return</span> eventTarget;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre><p>That&rsquo;s the full implementation of an <code>EventSource</code> (at least for my use case),
even the method to close connection is called <code>close()</code>, just as in real
<code>EventSource</code>. So you would use it like that (just an example, tune for your own
needs):</p>
<pre style="background-color:#f8f8f8;; overflow-x: auto"><code><span style="display:flex;"><span><span style="color:#a2f;font-weight:bold">const</span> xs <span style="color:#666">=</span> XhrSource(<span style="color:#b44">&#39;/your/api/url/&#39;</span>, {
</span></span><span style="display:flex;"><span>  method<span style="color:#666">:</span> <span style="color:#b44">&#39;POST&#39;</span>,
</span></span><span style="display:flex;"><span>  headers<span style="color:#666">:</span> {<span style="color:#b44">&#39;Content-Type&#39;</span><span style="color:#666">:</span> <span style="color:#b44">&#39;application/json&#39;</span>},
</span></span><span style="display:flex;"><span>  body<span style="color:#666">:</span> JSON.stringify({some<span style="color:#666">:</span> data})
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>xs.addEventListener(<span style="color:#b44">&#39;error&#39;</span>, e =&gt; {
</span></span><span style="display:flex;"><span>  outputEl.textContent <span style="color:#666">+=</span> <span style="color:#b44">&#39;ERROR: &#39;</span> <span style="color:#666">+</span> e.reason;
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>xs.addEventListener(<span style="color:#b44">&#39;close&#39;</span>, e =&gt; {
</span></span><span style="display:flex;"><span>  outputEl.textContent <span style="color:#666">+=</span> <span style="color:#b44">&#39;\nDONE&#39;</span>;
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>xs.addEventListener(<span style="color:#b44">&#39;message&#39;</span>, e =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#a2f;font-weight:bold">const</span> msg <span style="color:#666">=</span> JSON.parse(e.data);
</span></span><span style="display:flex;"><span>  outputEl.textContent <span style="color:#666">+=</span> msg.content;
</span></span><span style="display:flex;"><span>});
</span></span></code></pre><p>One interesting thing to note here is that <code>loadstart</code> event is sent
synchronously with the <code>xhr.send(opts.body)</code> call - before the <code>return</code>
happens. This feels weird to me, since no listeners are ready by the time&hellip; But
if I put <code>xhr.send</code> in <code>setTimeout</code>, then data starts arriving <em>visibly</em>
later. I have no idea why, profiling and looking at network panel did not give
me any insights, so if you know what&rsquo;s up or get other results or anything -
<a href="/about/">hit me up</a>, I&rsquo;d love to understand what is going on.</p>
<p>All in all it feels like this post took more time to write than the code - and
it&rsquo;s much simpler than DecodablePipe stuff from Rob&rsquo;s post. Works well for me,
so maybe it&rsquo;ll do the same for you. :)</p>
  ]]></content>
</entry>

<entry>
  <id>blog/2022/ngrok-for-the-wicked/</id>
  <author><name>Alexander Solovyov</name></author>
  <title type="html">ngrok for the wicked, or expose your ports comfortably</title>
  <published>2022-05-07T22:26:41Z</published>
  <updated>2022-05-07T22:26:41Z</updated>
  
  <category term="programming"></category>
  
  <link href="https://solovyov.net/blog/2022/ngrok-for-the-wicked/"></link>
  <content type="html"><![CDATA[
    
    
<p>I&#39;ve started using <a href="https://ngrok.com">ngrok</a> a lot lately (I know, I know, late to the party). But then last week, Homebrew has updated it to a version where it wants some $25 to supply custom domain names. I mean, I could pay that, but I&#39;m paying Hetzner like $8 or $9 for a server, and then I&#39;m paying for my domain and… it&#39;s still cheaper?</p>

<p>I understand that hosting is more commoditized than tunnels — I guess the market is wider — but still, it felt like I could spend a few hours and get something similar working. Why do I need custom domains? Because losing cookies makes me unhappy, and cookies require domain not to change. Plus, testing OAuth is really painful without custom domain since everybody wants to pin redirect URL to a stable domain.</p>

<p>There are many <a href="https://github.com/anderspitman/awesome-tunneling">open source alternatives</a>, the one I really liked is called <a href="https://github.com/anderspitman/SirTunnel">SirTunnel</a>. It&#39;s a small script which uses <a href="https://caddyserver.com/">Caddy</a>&#39;s JSON API to add and remove domains. But it got me thinking: why add and remove domains when I can just give another domain for some site forever? So if I started that site&#39;s process, it&#39;s working, and when not — well, you&#39;ll get <code>502 Gateway Timeout</code> on that particular domain, no big deal.</p>

<h2>The Plan</h2>

<figure><img src="https://images.solovyov.net/r/2022/5/1017268926.mermaid-diagram-20220508142410.png"/><figcaption></figcaption></figure>
<p>It&#39;s simple! I create a wildcard domain (something like <code>*.xxx.solovyov.net</code>, <code>xxx</code> is for &quot;real domain is none of your business&quot; 😁), and then reverse-proxy everything through an SSH tunnel from a server to my laptop.</p>

<p>I&#39;m still going to use Caddy since automatic HTTPS and laconic config appeals to me. 👍</p>

<p>Why do I need a local Caddy? Because SSH can proxy only single port and local processes occur on different ports. You can ignore that part if you don&#39;t need an ability to run multiple sites simultaneously. You know what, this case makes everything easier, since you require only one external domain for that rather than my wildcard setup.</p>

<h2>Execution</h2>

<p>So, I&#39;ve got a local Caddy working with many domains mapped to various ports. My plan is to run every project on a separate port, so when I start a process, it&#39;s immediately available to the world. Feels a bit exhibitionist, but very convenient. ☺️</p>

<p>Next was permanent SSH tunnel. I could&#39;ve done that myself, but I just found <a href="https://tyler.io/creating-a-permanent-ssh-tunnel-back-to-your-mac-at-home/">a recipe</a>.</p>

<p>The first problem is that handling <code>*.xxx.solovyov.net</code> in Server-Caddy makes Caddy request a wildcard certificate. And this requires integration of Caddy with DNS provider, which is limited to a few big providers. So, I opted to just repeating site definitions. :)</p>

<p>Next problem was… that it all worked! I could not believe my eyes. 🤣</p>

<p>But the story does not end here. You know what is irritating about ngrok? Latency. Especially when you&#39;re on a bad connection. </p>

<p>I mean my site is right here on my laptop but those roundtrips just to test OAuth and what not… Argh. I did not invent anything better than just writing that development domain in <code>/etc/hosts</code>. And. It. <strong>Worked</strong>! Too bad <code>/etc/hosts</code> does not support wildcards, so I&#39;ll have to repeat domains there too.</p>

<h2>Tutorial</h2>

<p>You&#39;ll need a domain name you own and control (really, control matters more than ownership here 😜) and a VPS somewhere in the world. Please do not copy and paste stuff blindly, you&#39;ll have to change at least the domain name for this to work. :)</p>

<h3>Domain</h3>

<p>Add <code>*.xxx</code> entry of type <code>A</code> to your domain pointing at your VPS.</p>

<h3>Local Caddy</h3>

<p><code>brew install caddy</code> — correct for your package manager — and start with this <code>Caddyfile</code>:</p>

<pre style="background-color:#f8f8f8;; overflow-x: auto"><code><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>	auto_https disable_redirects # so remote caddy is happy
</span></span><span style="display:flex;"><span>	email your@real.email # so you can debug problems with certs
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>(local) {
</span></span><span style="display:flex;"><span>	{args.0}.xxx.solovyov.net {args.0}.xxx.solovyov.net:80 {
</span></span><span style="display:flex;"><span>		encode zstd gzip
</span></span><span style="display:flex;"><span>		reverse_proxy localhost:{args.1}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>		handle_errors {
</span></span><span style="display:flex;"><span>			respond &#34;Local: {http.error.status_code} {http.error.status_text}&#34;
</span></span><span style="display:flex;"><span>		}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>		log {
</span></span><span style="display:flex;"><span>			level DEBUG
</span></span><span style="display:flex;"><span>			output file /opt/homebrew/var/log/caddy/{args.0}.log
</span></span><span style="display:flex;"><span>		}
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>import local dev 5000
</span></span><span style="display:flex;"><span>import local experiment 5001
</span></span><span style="display:flex;"><span>import local blog 5002
</span></span><span style="display:flex;"><span>
</span></span></code></pre>

<p>This <code>(local)</code> thingie is called <a href="https://caddyserver.com/docs/caddyfile/concepts#snippets">a snippet</a>. Now I can just copy this <code>import</code> line as many times as I want, not having to repeat those lines.</p>

<p>We instruct Caddy to listen to port <code>80</code> so that basic HTTP works. We need HTTP since our SSH tunnel targets this port. But HTTPS (domain without <code>:80</code>) is also nice to have — it makes external and internal setup more similar.</p>

<p><code>brew services start caddy</code> or equivalent to make Caddy run after system startup.</p>

<h3>Persistent SSH Tunnel</h3>

<p>Just follow <a href="https://tyler.io/creating-a-permanent-ssh-tunnel-back-to-your-mac-at-home/">a tutorial from Tyler</a>, should be simple enough. Your /.ssh/config entry should look like this:</p>

<pre style="background-color:#f8f8f8;; overflow-x: auto"><code><span style="display:flex;"><span>Host sshtun
</span></span><span style="display:flex;"><span>	HostName your.remote.server
</span></span><span style="display:flex;"><span>	RemoteForward 6800 127.0.0.1:80
</span></span><span style="display:flex;"><span>    ServerAliveInterval 60
</span></span></code></pre>

<p>What Tyler doesn&#39;t tell is that you have <code>launchctl load -w Library/LaunchAgents/your.plist.name.plist</code>, this little <code>-w</code> marks it enabled so <code>launchctl</code> will start it after restart (sounds like a common theme ain&#39;t it?).</p>

<p>In case you&#39;re not on macOS, use your system&#39;s process manager or look at <a href="https://www.harding.motd.ca/autossh/">autossh</a>.</p>

<p>Obviously, you can replace that part with some VPN solution, like Wireguard for an open-source solution or Tailscale like something more convenient.</p>

<h3>Remote Caddy</h3>

<p>No need to disable automatic redirects, so minimal version will look like this:</p>

<pre style="background-color:#f8f8f8;; overflow-x: auto"><code><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    email alexander@solovyov.net
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>(sshtun) {
</span></span><span style="display:flex;"><span>    {args.0}.xxx.solovyov.net {
</span></span><span style="display:flex;"><span>        encode zstd gzip
</span></span><span style="display:flex;"><span>        reverse_proxy localhost:6800
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>		handle_errors {
</span></span><span style="display:flex;"><span>			respond &#34;Remote: {http.error.status_code} {http.error.status_text}&#34;
</span></span><span style="display:flex;"><span>		}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        log {
</span></span><span style="display:flex;"><span>            output file /var/log/caddy/sshtun.log
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>import sshtun test
</span></span><span style="display:flex;"><span>import sshtun experiment
</span></span><span style="display:flex;"><span>import sshtun blog
</span></span></code></pre>

<p>You can see I&#39;m using snippets here as well, but the port is the same every time, since this is our SSH tunnel.</p>

<h3>Remote Caddy Wildcard</h3>

<p>After some thinking, I decided to try out wildcard setup anyway. I moved my domain to Cloudflare, since this is one of the providers supported by Caddy (filter by <code>caddy-dns</code> <a href="https://caddyserver.com/download">here</a> to see others), downloaded <a href="https://caddyserver.com/download?package=github.com%2Fcaddy-dns%2Fcloudflare">a custom Caddy build</a>, added a <a href="https://caddyserver.com/docs/build#package-support-files-for-custom-builds-for-debianubunturaspbian">diversion</a> (Debian/Ubuntu-specific) so that the regular package is in place (though I&#39;ll have to upgrade to new versions manually) and changed config to this:</p>

<pre style="background-color:#f8f8f8;; overflow-x: auto"><code><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    email alexander@solovyov.net
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>*.xxx.solovyov.net {
</span></span><span style="display:flex;"><span>	encode zstd gzip
</span></span><span style="display:flex;"><span>	reverse_proxy localhost:5900
</span></span><span style="display:flex;"><span>	handle_errors {
</span></span><span style="display:flex;"><span>		respond &#34;Server: {http.error.status_code} {http.error.status_text}&#34;
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>	tls {
</span></span><span style="display:flex;"><span>		dns cloudflare &lt;API TOKEN HERE&gt;
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>	log {
</span></span><span style="display:flex;"><span>		output file /var/log/caddy/d.log
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre>

<p>You can get Cloudflare API token <a href="https://dash.cloudflare.com/profile/api-tokens">here</a>. And it worked! I got wildcard certificate so no need to edit this config any more!</p>

<h3>/etc/hosts</h3>

<p>This is optional, but if you want the same glorious setup, add this to <code>/etc/hosts</code>:</p>

<pre style="background-color:#f8f8f8;; overflow-x: auto"><code><span style="display:flex;"><span>127.0.0.1 test.xxx.solovyov.net
</span></span></code></pre>

<p>but do not forget to use your actual DNS address. :)</p>

<h3>Adding a new domain</h3>

<p>There are a few edit points:</p>

<ul>
	<li>Local <code>Caddyfile</code> — this is non-optional to map name to port (again, I give different projects different ports so that I can start a few of them simultaneously)</li>
	<li>Remote <code>Caddyfile</code> — when there is no wildcard certificate</li>
	<li><code>/etc/hosts</code> — optional, to short-circuit browser</li>
</ul>

<p>In case something is not working, you will get different errors: </p>

<ul>
	<li><code>Server: ...</code> if there is no connection from your server Caddy to laptop&#39;s Caddy. Maybe your ssh tunnel is down, if everything is in place, <code>kill $(pgrep -f sshtun)</code> helps to force reconnect.</li>
	<li><code>Local: ...</code> if there is no connection from local Caddy to your process. Perhaps it&#39;s time to start your site? :)</li>
	<li>Other stuff should come straight from your process, so you know what to do.</li>
</ul>

<h2>The End</h2>

<p>And you know what? No need to start many ngrok processes occupying your terminal when you want a few of your sites running! <strong>Epic</strong>.</p>
  ]]></content>
</entry>

<entry>
  <id>blog/2022/postgresql-collation/</id>
  <author><name>Alexander Solovyov</name></author>
  <title type="html">PostgreSQL collation</title>
  <published>2022-05-05T14:07:49Z</published>
  <updated>2022-05-05T14:07:49Z</updated>
  
  <category term="postgresql"></category>
  
  <link href="https://solovyov.net/blog/2022/postgresql-collation/"></link>
  <content type="html"><![CDATA[
    
    <p>I&#39;ve got into a situation with PG I&#39;ve never been into before. There is a financial reports table, containing some description of a transaction, with columns like <code>date</code>, <code>amount</code> and <code>comment</code>. And this <code>comment</code> field is often used to search for something case-insensitively. This is done best using <code>where lower(comment) like &#39;%some words%&#39;</code> using trigram index:</p>

<pre style="background-color:#f8f8f8;; overflow-x: auto"><code><span style="display:flex;"><span><span style="color:#a2f;font-weight:bold">create</span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">index</span><span style="color:#bbb"> </span>report_comment_lower_trgm<span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">on</span><span style="color:#bbb"> </span>report<span style="color:#bbb"> 
</span></span></span><span style="display:flex;"><span><span style="color:#bbb">  </span><span style="color:#a2f;font-weight:bold">using</span><span style="color:#bbb"> </span>gin<span style="color:#bbb"> </span>(<span style="color:#a2f;font-weight:bold">lower</span>(<span style="color:#a2f;font-weight:bold">comment</span>)<span style="color:#bbb"> </span>gin_trgm_ops);<span style="color:#bbb">
</span></span></span></code></pre>

<p>But I&#39;ve been looking for one concrete thing and couldn&#39;t find, even though I knew it&#39;s there:</p>

<pre style="background-color:#f8f8f8;; overflow-x: auto"><code><span style="display:flex;"><span>finreport<span style="color:#666">=</span><span style="color:#666">#</span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">select</span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">count</span>(<span style="color:#666">*</span>)<span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">from</span><span style="color:#bbb"> </span>report<span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">where</span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">lower</span>(<span style="color:#a2f;font-weight:bold">comment</span>)<span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">like</span><span style="color:#bbb"> </span><span style="color:#b44">&#39;</span><span style="color:#b44">%кредиторськ%</span><span style="color:#b44">&#39;</span>;<span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">count</span><span style="color:#bbb"> 
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"></span><span style="color:#080;font-style:italic">-------
</span></span></span><span style="display:flex;"><span><span style="color:#080;font-style:italic"></span><span style="color:#bbb">     </span><span style="color:#666">0</span><span style="color:#bbb">
</span></span></span></code></pre>

<p>Yet, it turns out that just looking for a case-sensitive version works just fine:</p>

<pre style="background-color:#f8f8f8;; overflow-x: auto"><code><span style="display:flex;"><span>finreport<span style="color:#666">=</span><span style="color:#666">#</span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">select</span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">count</span>(<span style="color:#666">*</span>)<span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">from</span><span style="color:#bbb"> </span>report<span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">where</span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">comment</span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">like</span><span style="color:#bbb"> </span><span style="color:#b44">&#39;</span><span style="color:#b44">%Кредиторськ%</span><span style="color:#b44">&#39;</span>;<span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">count</span><span style="color:#bbb"> 
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"></span><span style="color:#080;font-style:italic">-------
</span></span></span><span style="display:flex;"><span><span style="color:#080;font-style:italic"></span><span style="color:#bbb">    </span><span style="color:#666">40</span><span style="color:#bbb">
</span></span></span></code></pre>

<p>What is even going on? After reading some articles, it seems like the main reason for this is an incorrect collation, so I went to check:</p>

<pre style="background-color:#f8f8f8;; overflow-x: auto"><code><span style="display:flex;"><span>finreport<span style="color:#666">=</span><span style="color:#666">#</span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">select</span><span style="color:#bbb"> </span>datcollate,<span style="color:#bbb"> </span>datctype<span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">from</span><span style="color:#bbb"> </span>pg_database<span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">where</span><span style="color:#bbb"> </span>datname<span style="color:#bbb"> </span><span style="color:#666">=</span><span style="color:#bbb"> </span><span style="color:#b44">&#39;</span><span style="color:#b44">asd</span><span style="color:#b44">&#39;</span>;<span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"> </span>datcollate<span style="color:#bbb"> </span><span style="color:#666">|</span><span style="color:#bbb"> </span>datctype<span style="color:#bbb"> 
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"></span><span style="color:#080;font-style:italic">------------+----------
</span></span></span><span style="display:flex;"><span><span style="color:#080;font-style:italic"></span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">C</span><span style="color:#bbb">          </span><span style="color:#666">|</span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">C</span><span style="color:#bbb">
</span></span></span></code></pre>

<p>Wow, well, obviously <code>C</code> is not very correct when I&#39;m looking at Ukrainian. Okay, is there a way to update collation? Some answers on Stack Overflow propose to just update <code>pg_database</code>, which is immediately did. Any guesses what happens next?</p>

<pre style="background-color:#f8f8f8;; overflow-x: auto"><code><span style="display:flex;"><span>finreport<span style="color:#666">=</span><span style="color:#666">#</span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">select</span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">count</span>(<span style="color:#666">*</span>)<span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">from</span><span style="color:#bbb"> </span>report<span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">where</span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">lower</span>(<span style="color:#a2f;font-weight:bold">comment</span>)<span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">like</span><span style="color:#bbb"> </span><span style="color:#b44">&#39;</span><span style="color:#b44">%кредиторська%</span><span style="color:#b44">&#39;</span>;<span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">count</span><span style="color:#bbb"> 
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"></span><span style="color:#080;font-style:italic">-------
</span></span></span><span style="display:flex;"><span><span style="color:#080;font-style:italic"></span><span style="color:#bbb">    </span><span style="color:#666">11</span><span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"></span>finreport<span style="color:#666">=</span><span style="color:#666">#</span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">select</span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">count</span>(<span style="color:#666">*</span>)<span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">from</span><span style="color:#bbb"> </span>report<span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">where</span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">lower</span>(<span style="color:#a2f;font-weight:bold">comment</span>)<span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">like</span><span style="color:#bbb"> </span><span style="color:#b44">&#39;</span><span style="color:#b44">%кредиторська%</span><span style="color:#b44">&#39;</span>;<span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">count</span><span style="color:#bbb"> 
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"></span><span style="color:#080;font-style:italic">-------
</span></span></span><span style="display:flex;"><span><span style="color:#080;font-style:italic"></span><span style="color:#bbb">     </span><span style="color:#666">3</span><span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"></span>finreport<span style="color:#666">=</span><span style="color:#666">#</span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">select</span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">count</span>(<span style="color:#666">*</span>)<span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">from</span><span style="color:#bbb"> </span>report<span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">where</span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">lower</span>(<span style="color:#a2f;font-weight:bold">comment</span>)<span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">like</span><span style="color:#bbb"> </span><span style="color:#b44">&#39;</span><span style="color:#b44">%кредиторська%</span><span style="color:#b44">&#39;</span>;<span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"> </span><span style="color:#a2f;font-weight:bold">count</span><span style="color:#bbb"> 
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"></span><span style="color:#080;font-style:italic">-------
</span></span></span><span style="display:flex;"><span><span style="color:#080;font-style:italic"></span><span style="color:#bbb">     </span><span style="color:#666">7</span><span style="color:#bbb">
</span></span></span></code></pre>

<p>I had not expected <strong>this</strong>! Well, I did all the things you do when things are going wrong: re-created index, analyzed table, restarted the database — and the last one finally changed something! It started giving me 0 rows back again. :)</p>

<h2>Solution</h2>

<p>Recreate database anew, so just dump and restore:</p>

<pre style="background-color:#f8f8f8;; overflow-x: auto"><code><span style="display:flex;"><span><span style="color:#080;font-style:italic"># stop all the things which can change db here</span>
</span></span><span style="display:flex;"><span>$ pg_dump finreport &gt; finreport.dump
</span></span><span style="display:flex;"><span><span style="color:#080;font-style:italic"># Postgres complained about &#34;template&#34; having other collation</span>
</span></span><span style="display:flex;"><span><span style="color:#080;font-style:italic"># and proposed using &#34;template0&#34;. I have no idea what&#39;s the</span>
</span></span><span style="display:flex;"><span><span style="color:#080;font-style:italic"># difference, but it worked.</span>
</span></span><span style="display:flex;"><span>$ createdb -l uk_UA.UTF-8 -E utf-8 --template<span style="color:#666">=</span>template0 qwe
</span></span><span style="display:flex;"><span>$ psql qwe -f finreport.dump
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#080;font-style:italic"># renaming databases</span>
</span></span><span style="display:flex;"><span>$ psql postgres -c <span style="color:#b44">&#34;select pg_terminate_backend(pid) from pg_stat_activity where datname = &#39;finreport&#39;&#34;</span>
</span></span><span style="display:flex;"><span>$ psql postgres -c <span style="color:#b44">&#34;alter database finreport rename to asd&#34;</span>
</span></span><span style="display:flex;"><span>$ psql postgres -c <span style="color:#b44">&#34;alter database qwe rename to finreport&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#080;font-style:italic"># don&#39;t forget to start all the stuff here and drop asd</span>
</span></span></code></pre>

<p>This obviously resets everything, and it now works as intended.</p>

<p>I&#39;m uncertain if various UTF-8 collations will be different. I hope not because in other case I have no idea how to deal with multiple languages in a single Postgres db.</p>
  ]]></content>
</entry>

<entry>
  <id>blog/2021/history-snapshotting-in-twinspark-js/</id>
  <author><name>Alexander Solovyov</name></author>
  <title type="html">History Snapshotting in TwinSpark</title>
  <published>2021-04-12T00:00:00Z</published>
  <updated>2021-04-12T00:00:00Z</updated>
  
  <category term="programming"></category>
  
  <category term="javascript"></category>
  
  <category term="kasta"></category>
  
  <link href="https://solovyov.net/blog/2021/history-snapshotting-in-twinspark-js/"></link>
  <content type="html"><![CDATA[
    
    
<p><a href="https://piranha.github.io/twinspark-js/">TwinSpark</a> is a library we use to write <a href="https://kasta.ua/">Kasta</a> frontend now. You can see examples of basic behavior by opening a link, but a cornerstone of TwinSpark is HTML-replacement functionality, which is what concerns us now.</p>
<p>You see, controlling app behavior can be done in various ways, but the major one is updating what user sees with a new markup from the server. And that potentially can change URL: like when user selects a filter in a product catalogue, this filter gets appended to a query string.</p>
<h2 id="go-back">&ldquo;Go back&rdquo;</h2>
<p>Fortunately, there is an API to change URL without reloading the page: <code>history.pushState</code>. User sees an updated URL and all is well. What happens if said user presses back button? He or she expects to see interface from before URL change. Not so fast! URL will change back, but HTML will stay!</p>
<p>What’s the solution? Well, <code>pushState</code> (and <code>replaceState</code>) accepts an argument called <code>state</code>. And before every <code>pushState</code> TwinSpark diligently did a <code>replaceState</code> for current URL placing current HTML in that storage:</p>
<pre style="background-color:#f8f8f8;; overflow-x: auto"><code><span style="display:flex;"><span>history.replaceState({html<span style="color:#666">:</span> <span style="color:#a2f">document</span>.body.innerHTML})
</span></span></code></pre><p>And when user presses back button, browser raises <code>popstate</code> event, indicating that user wants to go back in history. So you can listen to that event and then replace your current HTML with what&rsquo;s inside of <code>e.state.html</code>.</p>
<p>Sounds like it should work nicely, eh? Except for a little problem: Firefox has a limit of 640Kb for a single entry here (Chrome and Safari limits are much higher, so of no concern here). And we have endless scroll on product lists. Do you feel where this is going? Our Sentry is full of errors of Firefox users who have scrolled enough. And if they ever go back the behavior (restoring of correct HTML) is broken.</p>
<p><em>Aside</em>: we A/B-tested endless scroll relentlessly because every SEO expert says we should switch to separate pages because Google is really dumb. Not sure about Google being dumb, but endless scroll gives much better conversion rates than pagination. It seems humans hate excessive interaction more than Google hates us.</p>
<h2 id="real-go-back">Real &ldquo;go back&rdquo;</h2>
<p>It gets worse: what if you changed interface without changing URL and then user went to a separate, real page? Like, there is no <code>pushState</code>, no <code>popstate</code> event, what happens then? I&rsquo;ll tell you! Browser will show the oldest possible HTML for that whole sequence of <code>pushState</code>. I.e. whatever loaded on a last real page load.</p>
<p>This is a problem of planetary proportions, and there is no storage like <code>pushState</code> to store HTML. One of the solutions could be loading full HTML for the URL you&rsquo;re opening from a server, but that would be <strong>slooooow</strong>. Another is slapping some caching in <code>localStorage</code> and calling it a day:</p>
<pre style="background-color:#f8f8f8;; overflow-x: auto"><code><span style="display:flex;"><span><span style="color:#a2f">window</span>.addEventListener(<span style="color:#b44">&#39;beforeunload&#39;</span>, <span style="color:#a2f;font-weight:bold">function</span>() {
</span></span><span style="display:flex;"><span>  storeCache(location.href, <span style="color:#a2f">document</span>.body.innerHTML);
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span><span style="color:#a2f;font-weight:bold">if</span> (<span style="color:#a2f">window</span>.performance <span style="color:#666">&amp;&amp;</span>
</span></span><span style="display:flex;"><span>    performance.navigation.type <span style="color:#666">==</span> performance.navigation.TYPE_BACK_FORWARD) {
</span></span><span style="display:flex;"><span>  restoreCache(location.href);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre><p>You see, we&rsquo;ve found a weird API — <code>window.performance.navigation.type</code> — to check if you opened a page from a &ldquo;go back&rdquo; action, but <a href="https://caniuse.com/mdn-api_performance">it&rsquo;s supported</a> in IE9+ and Mobile Safari 9+, which makes it Good Enough™.</p>
<p>Too bad <code>localStorage</code> is often too small in Mobile Safari! It&rsquo;s 5MB, which is considerably more than 640Kb, but it&rsquo;s for all entries rather than a single one in Firefox&rsquo; <code>pushState</code>. Oh well&hellip;</p>
<h2 id="indexeddb">IndexedDB</h2>
<p>I wanted to unify approaches for a long time, and got sick to death of those errors. And the only viable solution to all those problems combined was IndexedDB, with its <a href="https://caniuse.com/indexeddb">great presence</a> almost everywhere we care, and high storage limits (IE is first to panic with a threshold of 250MB).</p>
<p>So approach is following: before every <code>pushState</code> or on <code>beforeunload</code> current <code>document.body.innerHTML</code> is being written to IndexedDB. After writing that stuff a count query is made and if it&rsquo;s over a (configurable, 20 by default) limit — excessive oldest entries are removed. This way if you go back you&rsquo;ll always get a most recent state of that URL — unless you go back too deep. :)</p>
<p>A <code>popstate</code> event handler replaces HTML, the same as earlier, just with more blood and tears because of IndexedDB API being a little bit less user friendly. What happens on real &ldquo;go back&rdquo; is more interesting though: instead of putting it in <code>DOMContentLoaded</code> handler, like everything else, I put it <a href="https://github.com/kasta-ua/twinspark-js/blob/3163611/twinspark.js#L992-L999">right in a middle of a script</a> itself. This way it manages to update HTML before everything else fires. Browser will even put you in a proper scroll position!</p>
<p>You can read all <a href="https://github.com/kasta-ua/twinspark-js/blob/27f2494c169699cddb658c2fd2b1471fd2b08507/twinspark.js#L339-L429">IndexedDB-related code here</a>. This cursor stuff will leave some marks on my soul, but I won&rsquo;t pretend I could design a better developer experience given all restrictions designers of that API had. Don&rsquo;t take my complains seriously, I&rsquo;m really happy that there is a solution for a problem, unlike often in that life.</p>
<p>I wonder if I need all that defensive programming in here, I could probably shave off some bytes if I remove it&hellip; That&rsquo;s a reason I&rsquo;m using IndexedDB API directly: I really don&rsquo;t want this library to be dependent upon other libraries. It&rsquo;s wrong and it&rsquo;ll add a lot more code that there already is.</p>
<p>If you feel like I did some mistakes and it could be done better, I&rsquo;m all ears! I certainly want this history snapshotting to be in a state where there is no need to think about it ever — I&rsquo;d be grateful for browsers to handle that for me, but let&rsquo;s work with what&rsquo;s available. Cheers!</p>
  ]]></content>
</entry>

<entry>
  <id>blog/2021/streaming/</id>
  <author><name>Alexander Solovyov</name></author>
  <title type="html">Code streaming: hundred ounces of nuances</title>
  <published>2021-01-11T00:00:00Z</published>
  <updated>2021-01-11T00:00:00Z</updated>
  
  <category term="video"></category>
  
  <link href="https://solovyov.net/blog/2021/streaming/"></link>
  <content type="html"><![CDATA[
    
    
<p>A few months ago I got a feeling that I&rsquo;d enjoy live streaming some coding. Not sure exactly, but it seemed to be somewhere along the lines of &ldquo;sitting at home,&rdquo; &ldquo;not enough people around to complain to&rdquo; and things like that. Plus it felt like it could spark some interesting discussions. Maybe. Also, this creates some time-based pressure to do smaller projects — because you&rsquo;re doing that every Wednesday (or whenever). And doing small things outside of general job-related stuff makes me feel like a person with wider interests. Who am I kidding though, it&rsquo;s all programming.</p>
<p>The issue here is that I can&rsquo;t just start doing something suddenly. I can&rsquo;t just buy a dishwashing machine  — I need to read up on what are the current features and trends and qualities and select the one I like. It&rsquo;s the same with streaming  — I can&rsquo;t stream using Macbook cam even though I&rsquo;m going to sit in a little sector of a video. I need a better webcam so I can feel like it&rsquo;s for real! So I set to research the topic.</p>
<h2 id="camera">Camera</h2>
<p>And it turns out <a href="https://vsevolod.net/good-webcams/">you can&rsquo;t buy a good webcam</a>. They are all shit — even if you pay $200+. If you want good picture quality the only way right now is to buy a photo camera with an HDMI capture card. All major brands have released drivers to connect cameras via USB, but those drivers are unanimously bad: USB2 can support compressed 1080p60, but those drivers only support 576p30. What is this madness I don&rsquo;t know, so you have to use an HDMI capture card.</p>
<p>The capture card could either be some <a href="https://aliexpress.com/item/4000917130635.html">cheap thingie</a> for $15 from Aliexpress, or an <a href="https://www.amazon.com/dp/B07K3FN5MR">Elgato Camlink 4K</a> for $130 (or other proper brand-name stuff for even more money). Initially I settled on a cheap crap and it works fine for now: it doesn&rsquo;t support 4K, and 1080p is also questionable, but the resulting video quality is miles ahead of webcams and phones. After some time I realized that it oversaturates image a lot, so I have to turn down colors in camera and end up with almost a grey-scale picture - and bought Camlink 4K, and result is just great.</p>
<p>For a camera you have two non-negotiable properties:</p>
<ol>
<li>It should be able to work on AC power. Working on batteries and a charger is insanity when it&rsquo;s a non-mobile working place.</li>
<li>It should output the so-called &ldquo;clean HDMI&rdquo;. This means that HDMI output should contain whatever the camera sees but without all the technical information, which is present on a camera screen — like a shutter speed or a battery level.</li>
</ol>
<p>The second one just out-ruled my otherwise excellent Fuji X-T1, because it outputs only recorded footage at the HDMI port. It was painful to realize, but I went to a local classified site and bought a used Sony a5100 for $250.</p>
<p>There are a few reasons why a5100:</p>
<ol>
<li>Sony&rsquo;s autofocus is the best on the market.</li>
<li>The sensor is good enough. I looked at <a href="https://www.dxomark.com/sony-a5100-sensor-review-uncompromising-performance/">dxomark</a> results and they are almost the same as much newer a6400 and a6600 have.</li>
<li>It has a flip-out screen that flips up 180° (sticking from the top of the camera). Excellent as a self-monitor, because it will work even if some of the components in the path fail.</li>
<li>It&rsquo;s dirt cheap. It&rsquo;s an older model made for 6 years already.</li>
</ol>
<p>I&rsquo;d prefer to have Fuji — their colors are so much better to look at — but I wanted to keep investment low just in case in two months I decide I don&rsquo;t need it all. And the cheapest Fuji setup would be 2x from a5100 — partially because Sony&rsquo;s kit lens is so cheaply made, ugh! Also, when I compared a5100 autofocus performance to my beloved X-T1 my jaw just dropped. Reviews suggest that newer Fuji cameras have improved greatly, but still are not on par with Sony.</p>
<p><img src="https://images-na.ssl-images-amazon.com/images/I/81HWDUTDpvL._AC_SL1500_.jpg" alt="fujifilm x-s10" /></p>
<p><strong>UPDATE</strong>. After being unhappy with the fact I sold my Fuji (despite not touching it for years) I decided that if I&rsquo;m so stuck with my gear obsession, it&rsquo;s better to satisfy it than suffer with it. So I&rsquo;ve bought Fujifilm X-S10 and Viltrox 23/1.4, and I couldn&rsquo;t be happier! First, image quality is much better with more detail. Then, colors are excellent. It&rsquo;s a Fuji&rsquo;s strength and I just love them even without color-grading. Plus instead of a dummy battery I just plug USB-C cable in! So I put it on a quick release Arca plate, and within 10 seconds I can have a great camera in my hands. It takes same 10 seconds to put it back on, so that&rsquo;s really low bar to make a better photo. Also, autofocus is good enough for me, reviews left me with impression that it&rsquo;ll be much worse. :)</p>
<h2 id="mount">Mount</h2>
<p>Of course, you can&rsquo;t just put a camera on your display. Webcams can do that, haha, should&rsquo;ve gone that way, right? Tripod is an option if you have a place to put it beside a table, but I don&rsquo;t. So I watched some YouTube videos to see how people mount cameras to film themselves and discovered a Magic Arm.</p>
<blockquote>
<p><em>Aside</em>: did I tell you I had to watch a lot of videos instead of reading text to discover everything? No? Now I did. It&rsquo;s a bit painful, but lots and lots of information are in video format only, and text articles about streaming are hard to find and incomplete. I guess including this one. :-)</p>
</blockquote>
<iframe width="400px" height="225px" src="https://www.youtube.com/embed/yfE00pXkL8U" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
<p>Back to the story. I never saw Magic Arms before, but the way they work is magical.</p>
<p>It seems that Manfrotto invented that stuff (can&rsquo;t find reliable sources, but I recall reading somewhere it was invented in 2008), but now everyone and their grandma are making clones of it. Clones are not even close in terms of quality to Manfrotto&rsquo;s products (or Matthews Infinity Arm, or other costly brands), but they are cheap! You can get a &ldquo;decent&rdquo; one along with a &ldquo;crab&rdquo; clamp for around 20-25$ here in Ukraine (and even cheaper on AliExpress). It&rsquo;s made from aluminum and will die on you if you use it a lot, of course, but the plan is to leave it sitting in place.</p>
<p>So I bought a monitor stand (it was around $30) and put a magic arm there, and a little $4 ball head, and — bam! — camera is looking right at me now.</p>
<h2 id="microphone">Microphone</h2>
<p>They say that there are three important things in a video, in that order:</p>
<ol>
<li>Sound</li>
<li>Light</li>
<li>Video</li>
</ol>
<p>And&hellip; did you know that camera&rsquo;s mic is the worst mic ever? By a weird coincidence — I tried to make a video with Fuji X-T1 a few times — I already knew that. They pick up weird frequency profiles (to a point where I can&rsquo;t listen to my voice from those mics), and they are <em>noisy as hell</em>. My MacBook&rsquo;s mic array is vastly superior to a camera mic! As you can guess to make all that feel more real I need an external microphone.</p>
<p>I knew next to nothing about mics, and I didn&rsquo;t just want to buy a Blue Yeti because it&rsquo;s a layman&rsquo;s choice. Plus I had an audio interface with XLR input and a 48V phantom power<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup>. So I went to read and watch some videos on mics and after some point stumbled upon <a href="https://www.neatmic.com/bee/worker-bee-microphone/">Neat Worker Bee</a> — see <a href="https://www.youtube.com/watch?v=ScI1U3ey9ZY&amp;t=630s">review</a> at Podcastage (which is the one of two channels I liked about audio, the other being <a href="https://www.youtube.com/user/curtisjudd">Curtis Judd</a>).</p>
<p><img src="https://images-na.ssl-images-amazon.com/images/I/81iDWBewlfL._AC_SL1500_.jpg" alt="worker bee image" /></p>
<p>It turns out Neat Microphones were a subsidiary of Gibson and made some upper-class mics: like Worker Bee for $200 and King Bee for $300. But after Gibson&rsquo;s bankruptcy Neat&rsquo;s CEO (and maybe some others, IDK) was able to buy it out. That CEO  — <a href="https://en.wikipedia.org/wiki/Skipper_Wise">Skipper Wise</a> — is also one of the founders of Blue Microphones. So Neat Mics are independent now and decided to change their pricing strategy. Unfortunately, King Bee is not produced anymore, but Worker Bee is sold for $90, which is a steal! It has loads of glowing reviews on YouTube and the design&hellip; it&rsquo;s funny, and it&rsquo;s different, and I just like it. Too bad that it&rsquo;s mostly black from the back (especially sitting in a cradle), so it&rsquo;s a bit less interesting in a captured video than I hoped. But I bought one anyway.</p>
<p>Other big brands you probably know of — like Audio-Technica and Shure — are great as well. There are some other great companies I&rsquo;ve never heard of before, like RØDE.</p>
<p>Various tests say that your listeners won&rsquo;t be able to tell the difference if you have any mic costing from about $100. Do whatever you want with that information.</p>
<p><img src="https://cdn2.rode.com/images/products/podmic/gallery/5.jpg" alt="rode podmic" /></p>
<p><strong>UPDATE</strong>. I really liked Worker Bee, both it looks and sound, but an appartment with small children is an unsuitable location for a condenser microphone: it picks up too much of a background noise. Dynamic microphones are much better at that, so I switched to RØDE PodMic. I like the sound of Worker Bee more, but slightly worse sound quality is an acceptable payment for the peace of my mind. Also, while not as intriguing as Worker Bee, it certainly doesn&rsquo;t look dull. Along with a yellow cable on a stand I think it provides nice direction to my face composition-wise.</p>
<h2 id="sound-processing">Sound processing</h2>
<p><img src="https://pae-web.presonusmusic.com/uploads/products/media/images/AudioBox_22VSL-02.png" alt="presonus audiobox 22vsl image" /></p>
<p>I mentioned that I already have an XLR interface to drive the mic — it&rsquo;s <a href="https://www.presonus.com/products/AudioBox-22VSL">Presonus AudioBox 22VSL</a>. It&rsquo;s a good interface with reasonably good pre-amplifiers. Can I complain? You bet! I&rsquo;m an expert complainer<sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup>!</p>
<p>And the main complaint is that it&rsquo;s just an audio interface. Let me elaborate.</p>
<p>There are a few things you could do with your voice: de-noise, compression, and equalization.</p>
<p>De-noise is self-describing, but important: when you stream, your laptop is going to make a lot of sound spinning coolers. This generates a lot of background noise which tires your listeners. What&rsquo;s more important, it drives me mad. This could be done in a simpler way, like noise gate — just silencing signal under some volume level, and in a more intelligent way, learning noise and then trying to clean the whole duration of a recording.</p>
<p>Compression is a really interesting way to improve voice signal: make quiet sounds louder and loud sounds quieter, &ldquo;compressing&rdquo; your sound volume to some middle level. This is good because then your distance to the microphone becomes less important, and what&rsquo;s especially good — changing distance to a mic is much less pronounced, with a volume level being more stable.</p>
<p>Equalizers are used for two purposes. First is altering the perception of recorded voice — add lows to make it sound fuller, remove them if it sounds &ldquo;muddy&rdquo;; add highs to make it airier, remove if it cuts your ears; alter mids for better understanding; you know, usual equalizer stuff. Another one is that mics are not perfect and the recorded voice could have some irritating frequencies. Go see <a href="https://www.youtube.com/watch?v=Jn6iB1SNvRQ">Curtis&rsquo; video</a> on that — it&rsquo;s pretty interesting, but it&rsquo;s a bit too hard. I tried looking at my recordings but it feels like it needs someone with a trained ear. :)</p>
<p>There are two ways how to do those things: either you&rsquo;re doing that on your computer or it&rsquo;s done in a DSP of your audio interface. DSP is vastly superior, of course: it does not add latency, there is no additional load on your CPU, and last, but not least, all your audio receiving apps will receive same processed sound, be it OBS, Zoom, browser or anything else. But, of course, 22VSL has no DSP — it&rsquo;s just an audio interface! So I have to do all that in software.</p>
<p><img src="https://vb-audio.com/Voicemeeter/VoicemeeterBananaMixer.jpg" alt="voicemeeter image" /></p>
<p>For software let&rsquo;s look at Windows side first. There is an app for Windows called <a href="https://vb-audio.com/Voicemeeter/banana.htm">VoiceMeeter Banana</a>, which replaces the system mixer and allows you to do all the things you want — compressor, eq, even some de-noising. And then silently sits in the tray. Nothing like that exists for macOS! Every article on the web tells you &ldquo;get a DAW and put a sound through there&rdquo;. DAW is something like SoundForge or Cubase or Ableton Live or Reaper. All of them are pretty expensive (Reaper is cheapest from 60$), eat a lot of memory, have highly cryptic interfaces, and are not resident apps. Each and every one of them thinks it&rsquo;s the <em>main</em> app of your life, eating like 10% of a core just to sit there. Ugh. GIVE ME BANANA!</p>
<h3 id="hardware-way">Hardware way</h3>
<p><img src="https://m.media-amazon.com/images/I/61S5wsiSHjL._AC_SL1000_.jpg" alt="yamaha ag03 image" /></p>
<p>I found two interfaces with DSP under $500: Yamaha AG03 (and AG06) for $170 and Steinberg (which is also Yamaha) UR22C (UR24C etc) for $250.</p>
<p><a href="https://usa.yamaha.com/products/music_production/interfaces/ag_series/ag03.html">Yamaha AG03</a> has a 4-line equalizer and a compressor, but no de-noise, which is a shame. I guess there is a reason why eq is only 4-line and not like 30-line, but this feels underwhelming. Reviews say that pre-amp could be better there&hellip; <a href="https://new.steinberg.net/audio-interfaces/ur22c/">Steinberg</a> looks like a more conventional interface, and has a few amplifiers emulation built-in (for guitars), but just a 3-line equalizer (what). Reviews on their audio quality are also not glowing, and some reviews on Steinberg complain that it&rsquo;s not working with top AMD chipsets (X470/X570), so it&rsquo;s not all sunshine and rainbows.</p>
<p>Alternatives from other manufacturers include <a href="https://www.behringer.com/product.html?modelCode=P0BI6">Behringer XR12</a> and <a href="https://motu.com/products/proaudio/ultralite-mk4">Motu UltraLite mk4</a>, which are more professional-grade devices and their size doesn&rsquo;t let me splurge on that.</p>
<p>There is UAD Apollo Solo for $700, and it&rsquo;s small, but 700?! Is it that hard?!</p>
<p><img src="https://cdn.rode.com/website/images/rodecasterpro/R%C3%98DE_R%C3%98DECaster_Pro_3_QUARTER_700x468+1.png" alt="rodecaster pro image" /></p>
<p><a href="https://www.rode.com/rodecasterpro">RØDECaster Pro</a> for $600 is widespread among professional streamers. It gives you 4 XLR inputs and Bluetooth connectivity so you can get someone on the phone into your stream. There are DSP and equalization presets — but no way to customize them, which is a shame.</p>
<p>There is also a <em>highly</em> popular <a href="https://www.tc-helicon.com/product.html?modelCode=P0CQK">TC Helicon GoXLR</a>, which does all processing in software. It&rsquo;s not useless though: you can assign apps on your PC to faders and have hardware control of their volume. So you can regulate your voice, music, game sounds, etc — very effective to manage what your viewers hear, right on your desk with a nice tactile feedback. But it doesn&rsquo;t work under macOS!</p>
<p>I guess you can see I&rsquo;m in search of a solution, so if you have any suggestions please let me know — I even made a Discourse for comments (see after the post).</p>
<h2 id="light">Light</h2>
<p>I just can&rsquo;t stop here. Like, I&rsquo;ve had two points out of three crossed, is it possible to stop? But light is the hardest one because it&rsquo;s much more about the physical world than anything else. It depends on your room, ability to place stuff around, and it can change anything drastically. Also, you can compare a camera to other cameras and buy the one you decided on, you can do the same with a mic, but with lightning, the specs of light you buy is not going to be the main thing. The main thing is placement. Tutorials say you have to have three different light sources: a key light, a fill light, and a back light.</p>
<p><img src="lightning-setup.jpg" alt="lightning setup" /></p>
<p>A <em>key light</em> is a bright source of light that should light up your face somewhat from the side so that you have some shadows. I put a Yongnuo YN300 right behind my camera.</p>
<p>A <em>fill light</em> should be less bright, its purpose is to reduce the harshness of those shadows on your face. I have a small lamp from the other side of my display which I reflect from the wall.</p>
<p>And a <em>back light</em> is something to highlight you from a back (surprise!) to add a feeling of depth to the image. I don&rsquo;t have a back light and my image is a little bit too plain because of that. I&rsquo;ll have to fix that.</p>
<p>There are also so-called <em>practical lights</em>, whose purpose is to add interesting points to your background so it&rsquo;s less dull. This starts to feel like I&rsquo;m going too far, though, I&rsquo;m not a &ldquo;real&rdquo; video maker yet but geared up as hell already, haha.</p>
<h2 id="various">Various</h2>
<p><img src="https://theawesomer.com/photos/2017/05/elgato_stream_deck_4.jpg" alt="stream deck image" /></p>
<p>There is also some streamer-oriented hardware I&rsquo;m not very excited about, like <a href="https://www.elgato.com/en/gaming/stream-deck">Elgato Stream Deck</a>. It&rsquo;s a keyboard with LCD screens under each key, where you can put various actions and sequences of actions (&ldquo;macros&rdquo;). So it&rsquo;s like <a href="https://www.hammerspoon.org/">Hammerspoon</a> + <a href="https://en.wikipedia.org/wiki/Optimus_Maximus_keyboard">Optimus Maximus</a>. The only reason I&rsquo;m mentioning it here is to tell a story of how <a href="https://www.ecamm.com/mac/ecammlive/">Ecamm Live</a> can switch screen configuration presets either from the app interface or by installing a plugin to Stream Deck. No AppleScript, no global hotkeys&hellip; it&rsquo;s like someone has a blind eye on power users? Anyway, good for Elgato, its product seems to be really popular.</p>
<p>The same Elgato has an interesting <a href="https://www.elgato.com/en/multi-mount-system">mount system</a>, but it&rsquo;s not only not sold in Ukraine and costs considerable amounts of money, but also is constantly out of stock.</p>
<p>Those Elgato people seem to be the most successful company oriented on the streaming market. Their Key Lights are also good, plus the software is excellent — you can control them from your phone.</p>
<p>I also feel like I need to link a few channels I liked (that talk about streaming) - <a href="https://www.youtube.com/c/EposVox">EposVox</a>, <a href="https://www.youtube.com/c/AlphaGamingHouse">Alpha Gaming</a> and <a href="https://www.youtube.com/c/TomBuck">Tom Buck</a>. I&rsquo;m sure there are other sensible channels, but those are that I stayed with.</p>
<h2 id="software">Software</h2>
<p>Default app for streaming is <a href="https://obsproject.com/">OBS</a>. It&rsquo;s an open source software, so it&rsquo;s free and full of capabilities, but, traditionally for open source, interface leaves much to be desired. So I decided to try out <a href="https://www.ecamm.com/mac/ecammlive/">Ecamm Live</a>, which costs money (subscription!), but is much nicer to use.</p>
<p>Particularly around multi-person presentations! We did <a href="https://www.youtube.com/watch?v=9fJXu_Htong">an online meetup</a> using Ecamm Interview: you start the app, it gives you a link to share and other people (up to 4 of them) can open that link and appear in the app as sources. Controls for configuring how you are displayed (side to side, picture-in-picture, only somebody) are excellent!</p>
<p>The way you can do that in OBS is to install <a href="https://github.com/Palakis/obs-ndi">obs-ndi</a> plugin, then configure Skype to stream there (because Skype supports streaming over <a href="https://en.wikipedia.org/wiki/Network_Device_Interface">NDI</a>), and then spend eternity resizing sources on your screen.</p>
<p>Of course, I have a few complaints for Ecamm Live. I mean, that&rsquo;s the curse of computers — even if you try to do something in a best way possible, there will be someone to complain. The first one is that there is no support for VST/AU plugins. OBS allows you to process your sound, Ecamm wants you to use external software. That would be OK if there were something good available but ARGH. :) Another one is price, but that&rsquo;s because I wanted Live Interview — in that case the cost is 40$/month. And the fact that you can setup multiple scenes (like just you, or just somebody, or two people together, or an intro screen), but you can&rsquo;t switch between them without activating Ecamm. No global hotkeys, no AppleScript support&hellip; this looks like an oversight, really. :\</p>
<h2 id="streaming">Streaming</h2>
<p>Now that I told you the gist of what I learned over a few weeks (I&rsquo;ll try to add more information, it&rsquo;s hard to extract knowledge from a human brain), let&rsquo;s get to the point of all that — streaming.</p>
<p>I did <a href="https://www.youtube.com/playlist?list=PL7gxcNpwRVlp1Xepntn5EiUFo0YjtT8ok">some</a>. It doesn&rsquo;t feel satisfying though. I know that to get some audience you have to persist, but the content should be interesting as well. 5 viewers suggest it&rsquo;s not yet.</p>
<p>I still feel the itch to produce some video content, but I guess it shouldn&rsquo;t be live coding. It&rsquo;s not exciting to watch some guy trying to figure out what the hell is going on with that SHA1 calculation for 10 minutes. Maybe diving deep in some bigger project would be more interesting, but I&rsquo;m not involved in a big open source right now, and showing innards of Kasta is something I&rsquo;m wary of.</p>
<p>Now my idea is to do a few (shorter) videos on various technical/programming topics. Stay tuned, <a href="https://www.youtube.com/c/asolovyov">subscribe</a> to my channel to get notifications, etc.</p>
<div class="footnotes" role="doc-endnotes">
<hr />
<ol>
<li id="fn:1">
<p>&ldquo;next to nothing&rdquo; is on the ternary scale, of course; phantom power is a current sent along XLR cable from a pre-amplifier to a condenser mic to drive it — it&rsquo;s like PoE!&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2">
<p>You could say that I&rsquo;m good at spotting market opportunities, of course.&#160;<a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>

  ]]></content>
</entry>

</feed>
