Fri, 01 Dec 2023 21:36:17 GMTNate Eaglenate@nateeagle.com/posts/how-to-add-an-in-game-chat-room-with-react/commentsNate Eagle<p>Hello Nate,<br>could you please check my app <a href="https://chat-real.netlify.app/">https://chat-real.netlify.app/</a> , i built it with react and firebase&#8230;i didnt use ably channel,so can you tell me some pros and cons, how my app is different from your app. here is my repo <a href="https://github.com/Stefan8412/chat">https://github.com/Stefan8412/chat</a>, thanks </p>
/posts/how-to-add-an-in-game-chat-room-with-reactNate Eagle<p><em>I recently published a freelance post for <a href="https://ably.com">Ably</a> that they published on <a href="https://ably.com/blog/how-to-add-an-in-game-chat-room-with-react">their company blog</a>. It was a ton of fun (and a lot of work!). I&#8217;m re-posting it here with permission. The header graphic was created by Ably. Enjoy!</em></p>
<img src="og-image.webp" style="width: 100%; max-width: 1297px" />
<p>One of the most fun parts of playing games with other people is talking to your opponent:</p>
<blockquote>
<p><em>&#8220;Have a good game!&#8221;<br>&#8220;Hmm&#8230; interesting move&#8230;&#8221;<br>&#8220;Your children&#8217;s CHILDREN will rue this day!&#8221;</em></p>
</blockquote>
<p>Online games are no exception: they&#8217;re a lot more fun if you can say hello to your opponent, or even debate some strategy. Many online games have built in chat mechanisms, but for more casual, web-based games, there are a lot of advantages to being able to add chat separately, in an abstracted way that can be applied to any game.</p>
<p>Ably makes it straightforward to add chat to a web application: it takes care of scaling and reliability concerns while packaging up a whole bunch of best practices and tools in their SDKs. It also works perfectly with serverless architectures, like Next.JS on Vercel, where you don&#8217;t have single processes running continually.</p>
<h2 id="heading-adding-chat-to-a-multiplayer-game"><strong>Adding chat to a multiplayer game</strong></h2>
<p>I&#8217;m going to take you through how to add chat to a simple multiplayer game, using Ably. I&#8217;m going to be using one of my favorite technology stacks on the web right now, but don&#8217;t worry if your stack looks a little different than mine. Almost everything we&#8217;re going to cover is broadly applicable to React + Ably. We&#8217;re going to be using Ably&#8217;s <a href="https://github.com/ably/ably-js/blob/main/docs/react.md">React Hooks</a>, part of their <a href="https://www.npmjs.com/package/ably">JavaScript SDK</a>, which make it impressively straightforward to use Ably&#8217;s features from inside a React app.</p>
<ul>
<li><a href="https://react.dev/">React</a></li>
<li><a href="https://www.typescriptlang.org/">TypeScript</a></li>
<li><a href="https://nextjs.org/">Next.JS</a></li>
<li><a href="https://vercel.com/">Vercel</a></li>
<li><a href="https://tailwindcss.com/">Tailwind CSS</a></li>
</ul>
<h2 id="heading-chat-features">Chat features</h2>
<p>We&#8217;re going to start with a straightforward chat app that lets two players of a multiplayer game exchange messages. Then, in subsequent posts, I&#8217;m going to walk you through how to enhance that app with some of the features that make a chat app more interesting and engaging. I&#8217;ll show you how to implement them in a compartmentalized, reusable way so that they can be added easily to any place in any app you might want to add chat functionality.</p>
<ul>
<li><strong>Typing Indicator:</strong> Show when the other person is currently typing, so you have added context about whether they&#8217;re working on a reply.</li>
<li><strong>Presence:</strong> Have your app respond to who&#8217;s actually present in the chat.</li>
<li><strong>Emoji Reactions:</strong> Let your users add emojis as an added way of responding to messages. π</li>
</ul>
<h2 id="heading-tic-tac-toe">Tic-tac-toe</h2>
<p><img src="tic-tac-toe-game.webp" alt=""></p>
<p>We&#8217;re going to take a simple game of tic-tac-toe as our starting point. If you&#8217;d like to bootstrap the project and run it on your own, you&#8217;ll need:</p>
<ul>
<li>An <a href="https://ably.com/sign-up">Ably account</a> β a free account is more than enough for this project.</li>
<li>A <a href="https://vercel.com/signup">Vercel account</a> β the hobby tier (free) is plenty.</li>
<li>A <a href="https://vercel.com/docs/storage/vercel-kv">Vercel KV Database</a> that will hold the game&#8217;s state (one KV DB is free for the hobby tier).</li>
<li>A <a href="https://github.com/neagle/tic-tac-toe/tree/1-initial-game">forked copy of our tic-tac-toe game</a> to start from, starting with the <code>1-initial-game</code> branch. When forking the repo in GitHub, make sure to untick the &#8220;Copy main branch only&#8221; option that is the default so that you bring over all the branches.</li>
</ul>
<p><img src="tic-tac-toe-forked-copy.webp" alt=""></p>
<p>To get things running locally, you&#8217;ll need your own versions of your own account credentials for Ably and for Vercel. The <a href="https://github.com/neagle/tic-tac-toe/tree/1-initial-game">repo</a> has an example <code>.env.template</code> file in its root to show you what values you need, so you can start by duplicating it to create an <code>.env</code> file that will contain your values.</p>
<pre><code class="language-sh">cp env.template .env
</code></pre>
<p>Then you should edit the .env file:</p>
<pre><code class="language-sh">ABLY_API_KEY=
KV_URL=
KV_REST_API_URL=
KV_REST_API_TOKEN=
KV_REST_API_READ_ONLY_TOKEN=
</code></pre>
<p><a href="https://ably.com/docs/ids-and-keys">How to find your Ably API Key</a>.</p>
<p><a href="https://vercel.com/guides/using-vercel-kv">How to create a KV store and get the credentials you&#8217;ll need from it</a>. Once you&#8217;ve added your own values, install the project&#8217;s dependencies and fire it up:</p>
<pre><code>npm i
npm run dev
</code></pre>
<p><img src="run-tic-tac-toe-game.webp" alt=""></p>
<p>Now you should be able to open up <a href="http://localhost:3000">http://localhost:3000</a> in a browser and see the app running:</p>
<p><img src="tic-tac-toe-waiting-for-opponent.webp" alt=""></p>
<p>If you want to try playing a game, open <a href="http://localhost:3000/">localhost:3000</a> in a different browser, or in a private / incognito window. (It won&#8217;t work to just open it in another tab: your tabs share the same localStorage, and therefore the same playerId.)</p>
<p><img src="tic-tac-toe-multiplayer.webp" alt=""></p>
<p>NOTE: If you ever manage to get into a broken game state while you&#8217;re working on this app, the simplest way to wipe the slate clean is to wipe clean your Vercel KV store. You can do that by accessing your KV Storage CLI in Vercel&#8217;s dashboard for your project storage and typing <code>FLUSHALL</code> to blow everything away.</p>
<p><img src="tic-tac-toe-flushall.webp" alt=""></p>
<h2 id="heading-how-the-game-works"><strong>How the game works</strong></h2>
<p>This implementation of tic-tac-toe is intended to be very simple.</p>
<ol>
<li>When a user visits the site, it checks if there&#8217;s already someone waiting to play.</li>
<li>If there&#8217;s an open game, start playing!</li>
<li>If not, start an open game and wait for an opponent.</li>
</ol>
<p><img src="how-to-build-a-chat-room-website-with-react-diagram.webp" alt=""></p>
<p>The state for the game is kept as a JSON object in Vercel&#8217;s KV Storage, which is just Redis, a highly performant and very popular way to store keys and values.</p>
<p>The game itself also uses Ably to send updates when players play moves. In this article, we&#8217;re not going to get into detail on how the game is constructed (so that we can focus on chat!), but I want to point out the aspects of our setup that get Ably up and running.</p>
<h2 id="heading-now-the-fun-part-adding-chat">Now the fun part: Adding chat!</h2>
<p>Ably&#8217;s channels let us segment realtime traffic so that everything isn&#8217;t part of the same stream. In this example, that maps naturally to the concept of games. Each game is its own channel, so that we can group all game-related messages together.</p>
<h3 id="heading-a-new-type-of-message-using-name">A new type of message using <code>name</code></h3>
<p>Within channels, we can diversify the messages we send with the <code>name</code> attribute. Using different <code>name</code> values for different types of messages lets us compartmentalize different kinds of information flowing back and forth in our channels.</p>
<p>The tic-tac-toe app comes out of the box using messages with the <code>update</code> name to let clients subscribe to updated game states published by the backend. To add chat-related functionality, we can start sending and receiving different kinds of messages. For basic chat, I don&#8217;t think we can go wrong with <code>message</code>.</p>
<h3 id="heading-a-new-component-chat">A new component: <code>Chat</code></h3>
<p>To add new chat functionality to the app, we&#8217;re going to want to encapsulate as much logic as possible in a new component. That new component can keep track of its own state and, ideally, make it easy to re-use the logic elsewhere.</p>
<p>This is what our <code>/src</code> directory looks like right now:</p>
<pre><code class="language-plaintext">src
βββ app
β βββ app.tsx
β βββ components
β β βββ Game.tsx
β β βββ grid
β β βββ Grid.tsx
β β βββ Pieces.tsx
β βββ favicon.ico
β βββ globals.css
β βββ layout.tsx
β βββ page.tsx
βββ gameUtils.ts
βββ types
βββ Game.ts
</code></pre>
<p>Because we&#8217;re going to add several pieces of chat-related functionality to the app before we&#8217;re done, let&#8217;s create a new folder in <code>components</code> called <code>chat</code>, and then, inside that, create a new file called <code>Chat.tsx</code>.</p>
<p>Since I&#8217;m using TypeScript, I also like to keep my types together in a central location. I&#8217;m using <code>/src/types/</code>, which is just one convention of many valid ones. Let&#8217;s also create a new <code>Chat.ts</code> file in types where we can put any types that we create for our chat. Here&#8217;s our updated file diagram:</p>
<pre><code class="language-plaintext">src
βββ app
β βββ app.tsx
β βββ components
β β βββ Game.tsx
β β βββ chat
β β β βββ Chat.tsx
β β βββ grid
β β βββ Grid.tsx
β β βββ Pieces.tsx
β βββ favicon.ico
β βββ globals.css
β βββ layout.tsx
β βββ page.tsx
βββ gameUtils.ts
βββ types
βββ Chat.ts
βββ Game.ts
</code></pre>
<p>Now, let&#8217;s set up the files. We always want to start simple and progress from there, so we&#8217;re going to just set up the component and make sure it works at the most basic level. I will start you out with a bunch of Tailwind classes that give this some design to start with, since design isn&#8217;t our focus in this article.</p>
<p>Let&#8217;s create a basic functional component in <code>Chat.tsx</code>:</p>
<pre><code>const Chat = () =&gt; {
return (
&lt;div className=&quot;chat&quot;&gt;
&lt;div&gt;
&lt;ul className=&quot;messages&quot;&gt;
&lt;li&gt;Hi there!&lt;/li&gt;
&lt;/ul&gt;
&lt;div className=&quot;chat-input&quot;&gt;
&lt;input
type=&quot;text&quot;
autoFocus={true}
/&gt;
&lt;button&gt;Send&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
);
};
export default Chat;
</code></pre>
<p>We havenβt got anything in here yet other than some HTML and classes for styling. We&#8217;ve created:</p>
<ul>
<li>A list where we can display chat messages.</li>
<li>An input where the user can type a new message.</li>
<li>A button the user can click to send their message.</li>
</ul>
<p>Next, let&#8217;s pull it into our <code>&lt;Game /&gt;</code> component. All we need to do is add an <code>import</code> for <code>Chat</code>, and then to add that <code>&lt;Chat /&gt;</code> component somewhere in the JSX that our functional component returns. Here&#8217;s the updated <code>Game.tsx</code> with some of the irrelevant content left out. The only bit of fanciness to note is that we&#8217;re going to only display the <code>Chat</code> component if there are two players in the game so that it doesn&#8217;t appear when there&#8217;s just a single player waiting on a game.</p>
<pre><code>import { createContext, useContext, useMemo } from &quot;react&quot;;
import * as Ably from &quot;ably&quot;;
import { useChannel } from &quot;ably/react&quot;;
import Grid from &quot;./grid/Grid&quot;;
import Chat from &quot;./chat/Chat&quot;;
import { useAppContext } from &quot;../app&quot;;
...
const Game = () =&gt; {
const { game, playerId, setGame } = useAppContext();
...
return (
&lt;GameContext.Provider value={{ opponentId }}&gt;
&lt;div className=&quot;game&quot;&gt;
&lt;Grid /&gt;
{game.players.length &gt; 1 &amp;&amp; &lt;Chat /&gt;}
&lt;/div&gt;
&lt;/GameContext.Provider&gt;
);
};
export default Game;
</code></pre>
<p>Make sure that you can see your new chat component when you have a game underway.</p>
<p><img src="tic-tac-toe-with-chat.webp" alt=""></p>
<h3 id="heading-building-out-the-chat-component">Building out the chat component</h3>
<p>At the high level, the requirements for our chat app are pretty simple. Here&#8217;s what we need:</p>
<ul>
<li>A channel id to join,</li>
<li>An instance of the Ably client,</li>
<li>A stateful array to hold messages,</li>
<li>A handler for our input to publish messages to the channel.</li>
</ul>
<p><strong>Channel ID</strong></p>
<p>If you read around <code>/src/app/app.tsx</code> and <code>/src/app/components/Game.tsx</code>, you might notice that we&#8217;re using Context to provide two additional sources of state to any component that wants to access it:</p>
<ul>
<li><code>AppContext</code>, which has information that&#8217;s applicable to the whole app.</li>
<li><code>GameContext</code>, which has information specific to each game.</li>
</ul>
<p>For our chat, we&#8217;re going to want to access the game&#8217;s ID to use as the channel name.</p>
<p>To access those values, we need to import <code>AppContext</code> and pull out the values we need inside the function:</p>
<pre><code>import { useAppContext } from &quot;../../app&quot;;
const Chat = () =&gt; {
const { game } = useAppContext();
...
};
</code></pre>
<p><strong>An instance of the Ably Client</strong></p>
<p>To use the Ably client we set up earlier, we&#8217;re going to use the <code>useChannel</code> hook provided by the Ably SDK&#8217;s React Hooks. We&#8217;ll create a handler that doesn&#8217;t do anything but log incoming messages to the console as a placeholder.</p>
<p>We&#8217;ll also import Ably to use its types so that we can describe the shape of incoming messages.</p>
<pre><code>// new imports
import { useChannel } from &quot;ably/react&quot;;
import * as Ably from &quot;ably/promises&quot;;
// inside the function component
const { channel } = useChannel(`game:${game.id}`, (message: Ably.Types.Message) =&gt; console.log(&#39;message:&#39;, message));
</code></pre>
<p><strong>A stateful array for chat messages</strong></p>
<p>For this, we&#8217;ll use React&#8217;s <code>useState</code>. Since we&#8217;re using TypeScript, let&#8217;s also create a <code>Message</code> type in <code>/src/types/Chat.ts</code> so we can describe the shape of the objects we&#8217;ll be storing.</p>
<pre><code>// src/types/Chat.ts
export type Message = {
clientId: string;
text: string;
timestamp: number;
id: string;
};
</code></pre>
<p>Also, let&#8217;s replace the existing placeholder <code>&lt;li&gt;</code> tag with a <code>{messages.map(message =&gt; ...)}</code> section inside of our JSX that will iterate over the messages array and display each individual message.</p>
<pre><code class="language-typescript">import { useState } from &#39;react&#39;;
import * as ChatTypes from &#39;../../../types/Chat&#39;;
const Chat = () =&gt; {
...
const [messages, setMessages] = useState&lt;ChatTypes.Message[]&gt;([]);
...
return (
&lt;div className=&quot;chat&quot;&gt;
&lt;div&gt;
&lt;ul className=&quot;messages&quot;&gt;
{messages.map((message) =&gt; {
const name = message.clientId;
return (
&lt;li key={message.id} className=&quot;user-message&quot;&gt;
&lt;b&gt;{name}:&lt;/b&gt; {message.text}
&lt;/li&gt;
);
})}
&lt;/ul&gt;
...
&lt;/div&gt;
&lt;/div&gt;
);
}
</code></pre>
<p><strong>A handler for our input to publish changes to the channel</strong></p>
<p>We&#8217;ll use a standard approach for a controlled input. We&#8217;ll create:</p>
<ul>
<li>A state value and setter for the input using <code>useState</code>.</li>
<li>A handler that updates the state using the setter when the input&#8217;s value changes.</li>
<li>A handler for the keyDown event that sends the message if the user hits &#8220;enter.&#8221;</li>
<li>A send function that uses the <code>channel</code> to publish the message and clear the input.</li>
</ul>
<pre><code class="language-typescript">// State for the chat text input
const [inputValue, setInputValue] = useState&lt;string&gt;(&quot;&quot;);
const onSend = () =&gt; {
// The first prop is the `name` (type) of the message
channel.publish(&quot;message&quot;, inputValue);
setInputValue(&quot;&quot;);
};
const onChange = (event: React.ChangeEvent&lt;HTMLInputElement&gt;) =&gt; {
const text = event.target.value;
setInputValue(text);
};
const onKeyDown = (event: React.KeyboardEvent&lt;HTMLInputElement&gt;) =&gt; {
if (event.key === &quot;Enter&quot; &amp;&amp; inputValue !== &quot;&quot;) {
onSend();
}
};
</code></pre>
<p>Then we just need to hook up these handlers to the elements themselves:</p>
<pre><code class="language-typescript">return (
&lt;div className=&quot;chat&quot;&gt;
&lt;div&gt;
&lt;ul className=&quot;messages&quot;&gt;
{messages.map((message) =&gt; {
const name = message.clientId;
return (
&lt;li key={message.id} className=&quot;user-message&quot;&gt;
&lt;b&gt;{name}:&lt;/b&gt; {message.text}
&lt;/li&gt;
);
})}
&lt;/ul&gt;
&lt;div className=&quot;chat-input&quot;&gt;
&lt;input
type=&quot;text&quot;
autoFocus={true}
onChange={onChange}
onKeyDown={onKeyDown}
value={inputValue}
/&gt;
&lt;button
onClick={onSend}
disabled={inputValue === &quot;&quot;}
&gt;
Send
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
);
</code></pre>
<p><strong>Test it out</strong></p>
<p>Before we start saving the messages, itβs a good idea to test what we have so far. If you open your console, you should be able to see a message you type into the input come through&#8230; to <em>both</em> the browser that sent it and to the other browser connected to the same game. It also gives you a chance to look at the structure of the messages that our channel publishes.</p>
<p><img src="tic-tac-toe-test.webp" alt=""></p>
<p><strong>Store incoming messages</strong></p>
<p>The last step is creating a handler that updates our <code>messages</code> state instead of just logging something to the console.</p>
<pre><code>const Chat = () =&gt; {
...
const onMessage = (message: Ably.Types.Message) =&gt; {
const { name, clientId, data, timestamp, id } = message;
if (name === &quot;message&quot;) {
setMessages((messages) =&gt; [
...messages,
{ clientId, text: data, timestamp, id },
]);
}
};
const { channel } = useChannel(`game:${game.id}`, onMessage);
...
}
</code></pre>
<p>Now, if you try sending messages through the chat, you should wind up with a bona fide chat window!</p>
<p><img src="tic-tac-toe-chat-window.webp" alt=""></p>
<p>In this case, the <code>playerId</code> is not a very elegant name to show in the chat screen. You could easily use a <code>username</code> instead if you have an app where a user has logged in. I am using random a generated <code>uuid</code> for each player in order to keep the app simple, and I have a helper function tucked in <code>src/gameUtils.ts</code> that will give us nicer names:</p>
<pre><code>import { playerName } from &quot;../../../gameUtils&quot;;
...
{messages.map((message) =&gt; {
const name = playerName(message.clientId, game.players);
return (
&lt;li key={message.id}&gt;
&lt;b&gt;{name}:&lt;/b&gt; {message.text}
&lt;/li&gt;
);
})}
</code></pre>
<p><img src="tic-tac-toe-player-names.webp" alt=""></p>
<p><strong>Channel history</strong></p>
<p>But what happens if the user reloads their browser? The chat messages disappear, since the component&#8217;s state only persists as long as the component stays in memory.</p>
<p>That may not be a problem, but if you want to have some channel history, one way to add that would be to add a persistence layer of some kind. Either save chat messages to a KV store or another database, or even just use <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage">localStorage</a>.</p>
<p>However, Ably&#8217;s channels also have the ability to <a href="https://ably.com/docs/channels/options/rewind">rewind</a> when joining a channel, to receive messages from &#8220;a point in time in the past, or for a given number of messages&#8221;. That&#8217;s perfect for our case. To use rewind we just need to use a special syntax to modify the channel name when we subscribe to it.</p>
<pre><code>const { channel } = useChannel(`[?rewind=100]${game.id}`, onMessage);
</code></pre>
<p>That will try to replay the last 100 messages, which is Ably&#8217;s limit.</p>
<p>Note that, by default, this history will only stick around for two minutes. If you want your channels to keep history for 24 hours, you need to add a channel rule. If you want to add channel rules for dynamic channels, like the ones we&#8217;re using in this app, you need to prefix your channel name with a namespace. I&#8217;ve already prefixed the channel names with <code>game:</code> so to enable persistence, we can add a channel rule using the Ably dashboard.</p>
<p>Go to your app, then click &#8220;Settings,&#8221; then scroll down to &#8220;Channel rules&#8221; and click &#8220;Add new rule.&#8221;</p>
<p><img src="ably-new-channel-rule.webp" alt=""></p>
<p>Add the namespace (&#8220;game&#8221;) and check &#8220;Persist all messages.&#8221;</p>
<p>Note the help text that warns you that persisting messages makes each message count twice against your monthly allocation, so you only want to enable channel history when it&#8217;s actually useful.</p>
<p><strong>System messages</strong></p>
<p>Now that we have a working chat, you know what might be nice? If the chat could also show us system messages about the game, like a message when one player wins.</p>
<p>I have a small secret: the game engine is already sending such a message when a game ends. We just need to have our frontend display it.</p>
<pre><code>{messages.map((message) =&gt; {
const name = playerName(message.clientId, game.players);
// It&#39;s a system message if our utility function doesn&#39;t have
// a name for the clientID
const isSystemMessage = !Boolean(name);
if (isSystemMessage) {
return (
&lt;li key={message.id} className=&quot;system-message&quot;&gt;
{message.text}
&lt;/li&gt;
);
} else {
return (
&lt;li key={message.id}&gt;
&lt;b&gt;{name}:&lt;/b&gt; {message.text}
&lt;/li&gt;
);
}
})}
</code></pre>
<p><strong>Clear the chat when the game changes</strong></p>
<p>One small issue you might notice if you play a few games is that the chat doesn&#8217;t always reset when you start a new game. Let&#8217;s clear messages whenever the <code>game.id</code> changes. React&#8217;s <code>useEffect</code> hook is perfect for that kind of synchronization.</p>
<p>Update the React import:</p>
<pre><code class="language-typescript">import { useEffect, useState } from &quot;react&quot;;
// Clear the chat when the game changes
useEffect(() =&gt; {
setMessages([]);
}, [game.id]);
</code></pre>
<p><strong>A couple presentational niceties</strong></p>
<p>While we&#8217;re here, let&#8217;s give the player names individual classes so that we can have them match the color of their pieces. Let&#8217;s also bring in a truly classic npm package for dealing with classes in React called <a href="https://www.npmjs.com/package/classnames">classnames</a> that also makes working with classes <em>much</em> more readable.</p>
<p>Here&#8217;s the final state of our <code>Chat</code> component:</p>
<pre><code class="language-typescript">import { useEffect, useState } from &quot;react&quot;;
import { useAppContext } from &quot;../../app&quot;;
import { useChannel } from &quot;ably/react&quot;;
import * as ChatTypes from &quot;../../../types/Chat&quot;;
import * as Ably from &quot;ably/promises&quot;;
import { playerName, playerNames } from &quot;../../../gameUtils&quot;;
import classnames from &quot;classnames&quot;;
const Chat = () =&gt; {
const { game } = useAppContext();
const [messages, setMessages] = useState&lt;ChatTypes.Message[]&gt;([]);
const onMessage = (message: Ably.Types.Message) =&gt; {
const { name, clientId, data, timestamp, id } = message;
if (name === &quot;message&quot;) {
setMessages((messages) =&gt; [
...messages,
{ clientId, text: data, timestamp, id },
]);
}
};
const { channel } = useChannel(`[?rewind=100]game:${game.id}`, onMessage);
// Clear the chat when the game changes
useEffect(() =&gt; {
setMessages([]);
}, [game.id]);
// State for the chat text input
const [inputValue, setInputValue] = useState&lt;string&gt;(&quot;&quot;);
const onSend = () =&gt; {
channel.publish(&quot;message&quot;, inputValue);
setInputValue(&quot;&quot;);
};
const onChange = (event: React.ChangeEvent&lt;HTMLInputElement&gt;) =&gt; {
const text = event.target.value;
setInputValue(text);
};
const onKeyDown = (event: React.KeyboardEvent&lt;HTMLInputElement&gt;) =&gt; {
if (event.key === &quot;Enter&quot; &amp;&amp; inputValue !== &quot;&quot;) {
onSend();
}
};
return (
&lt;div className=&quot;chat&quot;&gt;
&lt;div&gt;
&lt;ul className=&quot;messages&quot;&gt;
{messages.map((message) =&gt; {
const name = playerName(message.clientId, game.players);
const isSystemMessage = !Boolean(name);
if (isSystemMessage) {
return (
&lt;li key={message.id} className=&quot;system-message&quot;&gt;
{message.text}
&lt;/li&gt;
);
} else {
return (
&lt;li key={message.id} className=&quot;user-message&quot;&gt;
&lt;b
className={classnames(
{
&quot;player-x&quot;: name === playerNames[0],
&quot;player-o&quot;: name === playerNames[1],
}
)}
&gt;
{name}:
&lt;/b&gt;{&quot; &quot;}
{message.text}
&lt;/li&gt;
);
}
})}
&lt;/ul&gt;
&lt;div className=&quot;chat-input&quot;&gt;
&lt;input
type=&quot;text&quot;
autoFocus={true}
onChange={onChange}
onKeyDown={onKeyDown}
value={inputValue}
/&gt;
&lt;button
onClick={onSend}
disabled={inputValue === &quot;&quot;}
&gt;
Send
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
);
};
export default Chat;
</code></pre>
<p><img src="tic-tac-toe-with-chat-complete.webp" alt=""></p>
<p>Any issues? Compare what you have against the <code>2-chat</code> <a href="https://github.com/neagle/tic-tac-toe/tree/2-chat">branch</a>.</p>
<h2 id="heading-wrapping-up">Wrapping up</h2>
<p>Congratulations! We now have a working chat application added alongside our game. But there&#8217;s a lot more we can do to make chat more interesting.</p>
<p>In upcoming posts, I&#8217;ll show you how we can add a reusable custom hook to indicate when the other person is typing, how we can use Ably&#8217;s Presence feature to detect when the other person has left the chat, and how to let users add emoji reactions to individual messages. I hope you&#8217;ll join me! In the meantime, you can tweet Ably <a href="https://twitter.com/ablyrealtime">@ablyrealtime</a>, drop them a line in <a href="https://www.reddit.com/r/ablyrealtime/">/r/ablyrealtime</a>, or follow me on Twitter/X: <a href="https://twitter.com/neagle">@neagle</a>.</p>
<p><em>Originally written for Ably and posted on <a href="https://ably.com/blog/how-to-add-an-in-game-chat-room-with-react">their company blog</a>. Re-posted here with persmission.</em></p>
/posts/val-town-scraperNate Eagle<img src="sea-light-swizzles.png" alt="A screenshot of the Sea Light Swizzles showing a photograph of them above their title and a 'sold out' label." style="max-width: 400px" class="float-right" />
<p>I was doing some present shopping yesterday, and I was reminded of one of the best products I bought <em>myself</em> last year: a set of <a href="https://www.barebonestiki.com/shop/p/sea-light-swizzle-set-2geft">Sea Light Swizzles</a> from <a href="https://www.barebonestiki.com/">Bare Bones Tiki</a>. Sadly&#8211;and understandably&#8211;they were sold out. So what does any tiki-loving web developer in my shoes do? Set up a script to check the site every day and email me when they come back in stock.</p>
<p>There are myriad ways of doing this, of course, and I&#8217;ve used <a href="https://aws.amazon.com/lambda/">AWS Lambda</a> most recently for this kind of thing. But I&#8217;d recently heard about a site called <a href="https://www.val.town/">Val Town</a> that wants to provide a social, minimal-friction environment for people to run functions that interact with the rest of the web. The creator, Steve Krouse, was <a href="https://syntax.fm/show/640/supper-club-val-town-with-steve-krouse">recently on the Syntax Podcast</a>, and he talked about his background in teaching coding and how the desire to decrease the size of the hurdle for new programmers in transitioning from more batteries-included development environments like <a href="https://scratch.mit.edu/">Scratch</a>.</p>
<p>Making things easier for novices is well and good, but even those of us who&#8217;ve spent (mumble mumble) years coding for a living have a whole list of things we&#8217;d like to do that we <em>really</em> don&#8217;t want to spend that much time setting up or maintaining. Quick and easy never goes out of style, especially when you&#8217;ve got the work you actually get paid to do waiting for you.</p>
<p>So, when the need to set up a site-checking script came up, I thought I&#8217;d give Val Town a shot.</p>
<h2 id="heading-preview">Preview</h2>
<p>I&#8217;ll write a little bit about my experience figuring things out, but I want to give the final result first for anyone who just wants the code.</p>
<p><a href="https://www.val.town/v/hurricanenate/webscrapeBareBonesTiki">@hurricanenate/webscrapeBareBonesTiki</a></p>
<iframe src="https://www.val.town/embed/hurricanenate/webscrapeBareBonesTiki" class="val-town embed" style="height: 908px"></iframe>
<p>(Note that Val Town vals use Deno, which means some of the APIs will be different than what you might be used to if you only use NodeJS. It also affects how you import packages. I&#8217;m a big fan of Deno and use it to power this very site.)</p>
<p>This function fetches the HTML of the Bare Bones Tiki swizzles page, parses it, uses some fancy DOM traversal to check for the status of the item I&#8217;m interested in, and then emails me with whether it&#8217;s currently sold out.</p>
<p>Val Town lets me set the val as an <a href="https://docs.val.town/scheduled-vals">Interval</a>, which has a cron set on it that will run it once a day.</p>
<h2 id="heading-other-peoples-vals">Other People&#8217;s Vals</h2>
<p>One of Val Town&#8217;s goals is to make it not only as easy to share bits of code as it is to post to social media sites, but to make it easy to <em>use</em> those bits of code. In my val, I&#8217;m using <code>fetchText</code> to grab the content of the URL for the product I&#8217;m checking, which is just another <a href="https://www.val.town/v/stevekrouse/fetchText">val by Steve Krouse</a>.</p>
<pre><code class="language-typescript">import { fetchText } from &quot;https://esm.town/v/stevekrouse/fetchText?v=5&quot;;
</code></pre>
<p>Their interface makes it easy to get other people&#8217;s vals: just type <code>@</code> and start typing a name, and you&#8217;ll see a typeahead open up showing all the public vals that might match your search. (Vals can also be set to unlisted or private.) If you select one, the editor will add the correct import to the top of your val automatically. It&#8217;s like having an extremely granular <a href="https://npmjs.com">npm</a> within easy reach.</p>
<h2 id="heading-using-the-dom">Using the DOM</h2>
<img src="dom-inspection.png" style="width: 100%; max-width: 800px" alt="A screenshot showing the product page with Chrome's inspector tools open to the side." />
<p>Parsing the DOM of the product page is the only particularly involved logic in my val. The only way to select the correct element was by its text, so I had to select every product title, find the one with the text I wanted, go back up to its container, then back down to grab the status element that said &#8220;SOLD OUT.&#8221; (Val Town&#8217;s <a href="https://docs.val.town/web-scraping">web scraping reference</a> has some useful help for learning to do this type of thing.)</p>
<p>It took me a little bit of trial and error to figure out the best way to interact with the DOM in my script. <a href="https://docs.val.town/web-scraping">Val Town&#8217;s example</a> recommended using <a href="https://www.npmjs.com/package/cheerio">cheerio</a>, but I didn&#8217;t want to use a specialized API for DOM traversal. Now that jQuery has remade the modern DOM API in its own image, I&#8217;d much rather use standard DOM methods.</p>
<p>Unfortunately, when I tried to use <a href="https://www.npmjs.com/package/jsdom">jsdom</a>, a popular and venerable library, I ran into <a href="https://github.com/denoland/deno/issues/17593"><code>Not implemented: isContext</code></a> errors: it seems like <code>jsdom</code> is tough to run in Deno. I found a recommendation for <a href="https://webreflection.medium.com/linkedom-a-jsdom-alternative-53dd8f699311">linkedom</a> as an alternative, and it works great.</p>
<pre><code class="language-typescript">// Val Town uses Deno, so it&#39;s easy to use imports from https://esm.sh
import { DOMParser, Node } from &quot;https://esm.sh/linkedom@0.16.1&quot;;
const URL = &#39;any url&#39;;
export const genericWebScraper = async () =&gt; {
const html = await fetchText(URL);
const document = new DOMParser().parseFromString(html, &quot;text/html&quot;);
// Now you can use document.querySelector() or any other document method just
// as you would from your console.
}
</code></pre>
<p>It was a little annoying to figure out how to make TypeScript happy. (I may have said a few things off the record about hating TypeScript &#8220;so, so much.&#8221;) I&#8217;ve been working with TypeScript for the last few months to stay professionally relevant, and because it really does have significant benefits when working with shared code bases, and when apps grow in complexity. But I wish there were the option to turn off the TypeScript validation in the Val Town code editor, where the simpler and more exploratory context makes me prefer vanilla JavaScript. </p>
<p>You can import the <code>Node</code> type from <code>linkedom</code>, too, and then use it to create a <a href="https://www.typescriptlang.org/docs/handbook/advanced-types.html#type-guards-and-differentiating-types">type guard</a> to assure TypeScript that whatever we get back from <code>querySelectorAll</code> is actually an HTML Element.</p>
<pre><code class="language-typescript">function isHTMLElement(node: Node): node is HTMLElement {
return node.nodeType === Node.ELEMENT_NODE;
}
</code></pre>
<h2 id="heading-how-to-email-yourself">How to Email Yourself</h2>
<p>It&#8217;s simple, but one of the most appealing hooks of Val Town is just how easy it makes it to email yourself. (On the <a href="https://www.val.town/pricing">pro plan</a> you can apparently use it to email any address.) It has a <a href="https://www.val.town/u/std">library of standard vals</a> with a ton of useful tools, including <a href="https://www.val.town/v/std/email">email</a>.</p>
<pre><code class="language-typescript">import { email } from &quot;https://esm.town/v/std/email?v=9&quot;;
export const myFunc = async () =&gt; {
await email({
subject: &quot;GREETINGS&quot;,
text: &quot;Hello, creator!&quot;,
})
}
</code></pre>
<h2 id="heading-val-town-on-discord">Val Town on Discord</h2>
<p>I was running into some significant challenges getting my val working, initially, partially because it turned out Val Town was doing a deploy that partially impacted availability while I was working, and then I got hit by an actual UI change that I didn&#8217;t understand. So I found my way to <a href="https://discord.gg/dHv45uN5RY">Val Town&#8217;s Discord</a> and posted a question&#8230; to which the creator Steve Krouse himself responded, inviting me to pair with him on Zoom for a few minutes.</p>
<p>He was able to quickly explain how to get arround the issue that had been tripping me up (involving the distinction between just saving a val and running the function that the val exports), while mining my valuable newcomer&#8217;s ignorance about how things worked to gather some user testing data.</p>
<p>Suffice it to say that hopping on Discord looks like a great way right now to get help on any questions or issues you might run into.</p>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>Val Town is in its early days, and their <a href="https://blog.val.town/blog/introducing-val-town-v3">last release</a> rolled out some major changes, including breaking ones. But it&#8217;s an exciting platform, and it will absolutely be what I reach for next time I want to create a small, specific tool or utility to get something done. It feels as easy as something like Zapier or IFTT, but without the barrier between you and <em>the code</em> that their models involve. Consider me hooked!</p>
/posts/split-the-costs-a-proposal-for-developer-conferences-and-trainingNate Eagle<div class="image float-right">
<img src="tree-planting.jpg" class="float-right" style="max-width: 600px;" />
<p class="credit">Photo credit: <a href="https://flickr.com/photos/ukinusa/52080394383">UKinUSA</a></p>
</div>
<p>Companies want the developers who are working for them to grow. Developers themselves want to grow, and often ask specifically about growth paths and opportunities as part of their interview process. There are many ways to grow as a developer, but two major ones are conferences and other types of training that have both a monetary and time cost. In my experience, companies are stuck in a frustrating binary regarding those options: either pay for everything, or pay for nothing. Neither of these options is ideal.</p>
<p>(Note: I am not talking, in this post, about examples where a company needs to train an employee for something based purely on the company&#8217;s needs. If you need your employee to get certified in a particular technology to perform a new job function, for instance, that should be on the company&#8217;s dime. The same is obviously true if an employee wants to take a training course in, say, bonsai.)</p>
<p>From the company&#8217;s perspective, paying for an event or training can be an expensive proposition. For a conference, for example, you&#8217;ve got travel costs, hotel costs, event fees, and then the days the employee won&#8217;t be working on company tasks but still wants to be paid. The total package can easily be thousands of dollars. That leaves the company asking: how can we justify this?</p>
<p>So the company asks the employee for a clear argument about how the event will benefit the company, how it will give them specific, tangible skills that will impact what the company is doing right now. And it might ask the employee to give a presentation on what they learned when they return, based on the hope that spreading out the knowledge gained will produce some added value to help further balance the scales against the cost outlay. The company <em>gave</em>, the employee <em>got</em>, and now the company has to do its best to extract at least some value out of the transaction.</p>
<p>On the other hand, the employee could just pay for everything, including not using work days/time. But, again, the costs are steep. It&#8217;s not easy to go to your partner and explain that you&#8217;re going to use the money that could cover a family vacation for some personal skill development. If you do it outside of work hours or you use PTO, you&#8217;re giving up time with your family and time to pursue the personal endeavors that don&#8217;t make you any money but make life <em>meaningful</em>.</p>
<p>This steep cost is also likely to make developers prioritize the things that are most valuable to them personally, as opposed to things that are a mix of usefulness for them and their company. And if you&#8217;re out there fending for yourself in your skill growth, why think of your company as being that much of a partner? You&#8217;ll be more likely to be on the hunt for a new job that&#8217;s directed mostly by your own interests.</p>
<p>Neither side is being unreasonable in these examples. But having all of the costs on one side or the other places the two parties in an adversarial rather than cooperative relationship.</p>
<h2 id="heading-split-the-costs">Split the Costs</h2>
<p>My proposal is simple: split the costs! For conferences or training, both the company and the employee will benefit. Therefore, it is most fair for both parties to contribute to the creation of that benefit. And if both parties have skin in the game, both have incentive to work together to maximize that benefit.</p>
<img src="polypots.webp" class="float-right" style="max-width: 375;" />
<p>As an example: before I began my career as a developer, I was a Peace Corps Volunteer in Cameroon, promoting Agroforestry. We were promulgating techniques for using trees in combination with agriculture to preserve soil fertility and fight desertification. As part of that, we taught farmers to create tree nurseries using polyvinyl sacks (polypots) to grow seedlings in that could then be planted when they had developed to a certain point. We were given supplies of those polypots to give out, but we were strictly instructed to <em>sell</em> them to farmers, not give them away. These were often poor farmers, and asking them for money felt like doing the opposite of what he had come there to do, but the reason we were given was simple: if you give the polypots away for free, you tend to find them sitting in a yard, months later, unused. If you charge a fee&#8211;even a very small one&#8211;they are far more likely to be used. The farmer&#8217;s relationship with the polypot changes based on whether they&#8217;ve put money into getting them.</p>
<p>For companies and developers, therefore, the details of how costs are split is less important than whether they are split at all. If each party has paid for the conference or training, each is more motivated to put that experience to good use.</p>
<p>The company could offer to pay for 75% of conference fees, for instance, and cover the days off work for the employee. The employee could be responsible for 25% of the conference fee as well as travel and lodging. It&#8217;s still a lot of money for the company, but it&#8217;s <em>also</em> a lot of money for the employee, and it gives them a reason to really consider the relative merit of conferences nearby versus conferences across the country.</p>
<p>And it changes the relationship: the company should be less worried about being taken advantage of in some way. The employee isn&#8217;t just getting a paid vacation to go enjoy happy hours in a far-off city. Ideally, it can back off of trying to extract concrete, directly traceable value and embrace some of the more abstract benefits. Did this developer come back with renewed enthusiasm for their job? Did they come back with some fresh ideas about tools to use? Did they come back with a list of relevant, modern companies in their head that might come in useful at surprising moments when looking at solving problems at work? Might they end up staying at your company a year longer than they otherwise would, thus saving the company thousands of dollars in recruiting fees and time spent paying other staff to interview candidates.</p>
<p>The developer, on the other hand, will have a different experience at the conference knowing <em>they&#8217;ve paid</em> to be there. It&#8217;ll be a motivating factor in really paying attention to those conference talks, to making use of the opportunity of talking to other developers and making contacts with vendors whose products might be relevant to them and their company.</p>
<p>With training, the company could offer to provide paid work time for the training while having the developer pay for the training itself. The company could provide a couple of hours each day, for instance, and have a timeline attached with some kind of target. (Certifications would work great for that.) </p>
<p>Again: when both parties have skin in the game, neither has to worry about being taken advantage of. The developer might feel a lot better about pursuing training later into the evening after work hours since they got to at least get in some of the training during the work day.</p>
<p>There&#8217;s plenty of room to be thoughtful about the specifics of how splitting the cost would work in individual situations: the important thing is the principle. There are very real benefits to developer skill growth, and those benefits are shared. Splitting the costs of creating those benefits is both equitable and motivating.</p>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>The tech world is an innovator in the landscape of company/employee relationships, and while there have been some wrong turns (ping pong tables in the middle of work areas have so many problems), it&#8217;s led the way in embracing the potential of a cooperative spirit between companies and employees.</p>
<p>It&#8217;s time to break out of a limiting binary regarding professional growth and development. By sharing the financial and time investments, we can cultivate a culture where both parties are invested in the positive outcomes of training and conferences.</p>
<p>I&#8217;m eager to hear other people&#8217;s perspectives. Whether you are an employer, a developer, or someone from another industry with an interest in the general idea, I&#8217;d love to hear your thoughts. How feasible and appealing do you think this concept would be in the real world?</p>
/posts/serverless-websockets-with-ablyNate Eagle<p><a href="https://serverless-chat-neagle.vercel.app/"><img src="serverless-chat.png" style="width: 100%; max-width: 800px; margin-bottom: 1em;" class="float-right" /></a></p>
<p>I&#8217;m working on modernizing an app that I developed almost a decade ago from a MEAN (Mongo, Express, Angular, NodeJS) stack to a serverless stack using NextJS. But one sticking point has been the need for WebSockets: my app uses WebSocket connections to deliver updates to users without the need for polling. They&#8217;re ideal for any app where multiple users interact with a site at the same time and it&#8217;s important to see when other users add content (like making comments).</p>
<p>The first option I tried exploring was <a href="https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events">Server-Sent Events</a>, or SSEs. SSEs are similar to WebSockets, but they&#8217;re one-way only. For me, that&#8217;s enough: I let the client send information to the server using REST endpoints (which works great with serverless); I just need a mechanism for sending information to the client when they haven&#8217;t explicitly asked for it. (Which would just be polling.)</p>
<p>SSEs come dangerously close to working for serverless, and I completed <a href="https://github.com/neagle/serverless-chat/commit/f4d40f724f4b2a5e980ea50ab358cb5d4fbbaf61">an entire proof-of-concept chat application in NextJS</a> that was working swimmingly in local development. The only problem: it relied on keeping <a href="https://github.com/neagle/serverless-chat/commit/f4d40f724f4b2a5e980ea50ab358cb5d4fbbaf61#diff-486fb3c4e9b74759702ac1069f11783ffc358577373d0521f05fd476a0551f5cR6">an array of client objects in memory</a>. One dangerous thing about serverless functions is that this will work <em>some of the time</em>. Because <em>sometimes</em> serverless functions are run on the same computer when they&#8217;re executed, but that&#8217;s not a guarantee. Serverless functions are ephemeral by design, so in-memory storage won&#8217;t scale once deployed.</p>
<p>I thought: that&#8217;s fine&#8211;I&#8217;ll just use an external store to keep track of the clients. So I hooked up <a href="https://vercel.com/docs/storage/vercel-kv">Vercel KV</a>, which is just a conveniently packaged version of Redis, where I could store my clients and retrieve them every time my function executed. No sweat! Everything was going great right up until I tried to <code>JSON.stringify</code> my clients array and received this:</p>
<pre><code>Error [TypeError]: Converting circular structure to JSON.
</code></pre>
<p>Oh. Ohhhhhhhh. <em>Oh.</em> Not every object in JavaScript can be represented in JSON &#8211; circular references can be a feature, not a bug. And the clients for SSEs are actual response objects, which can&#8217;t be reduced to JSON and then rehydrated afterward. It&#8217;s a hard reality, for the moment, but there seems to be no way to escape the need for a process that <em>keeps things in memory</em> when it comes to realtime communication.</p>
<h2 id="heading-third-party-websocket-providers">Third-Party WebSocket Providers</h2>
<p>Vercel, of course, <a href="https://vercel.com/guides/publish-and-subscribe-to-realtime-data-on-vercel">seems to say the same thing</a> and provides a list of third-party providers that can make realtime communication possible. They don&#8217;t spell out the reason you have to use a third party, which is one small quibble I have with their documentation.</p>
<p>The modern Jamstack is great in many ways, but it has the disadvantage of fragmenting what was once a single account and provider (in my case, <a href="https://www.linode.com/">Linode</a> provided my VPS) into a whole portfolio. The advantage of using a specialized provider is usually that they&#8217;ll do something particular really well, or make it particularly easy to use, but the disadvantage is having to manage a separate integration and think about the potential implication of those costs.</p>
<p>So I&#8217;m here to tell you: in the year 2023, at least, the nature of functions as a service, which in their very nature require that they not guarantee running on the same computer, makes it necessary to reach to a separate service for real-time, non-polling communication.</p>
<h2 id="heading-enter-ably">Enter Ably</h2>
<p>I reached for <a href="https://ably.com/">Ably</a> because they had a booth at the 2022 <a href="https://jamstack.org/conf/">Jamstack Conf</a>, and probably gave me a sticker or something. (You hear that? Sponsor your local tech conference!) They have a <a href="https://ably.com/pricing">generous free tier</a>, which is an absolute requirement for this space, where devs like me usually want to try a product on something that doesn&#8217;t make us any money, and then that translates (ideally) into recommending it in our professional lives for products that <em>do</em> make money, where paying for higher levels of invocations, compute time, or <em>whatever</em> makes sense.</p>
<p>Integrating with Ably was (relatively) painless, though I want to go on record and recommend avoiding their <a href="https://www.npmjs.com/package/@ably-labs/react-hooks">react hooks</a> package, which I found tripped me up more than it helped, especially as it had <a href="https://github.com/ably-labs/react-hooks/issues/8">an outstanding bug</a> at the time of writing that affected me. But the good news is that using Ably&#8217;s standard client in React isn&#8217;t that tricky.</p>
<h2 id="heading-serverless-chat-app">Serverless Chat App</h2>
<p><a href="https://serverless-chat-neagle.vercel.app/">Here is our Serverless Chat app</a>. You can open it in multiple tabs to see the chat functionality in action. And <a href="https://github.com/neagle/serverless-chat">here&#8217;s the project on Github</a> if you want to dive straight into the source code.</p>
<p>Let&#8217;s walk through the ingegration. This starts from an example NextJS boilerplate, which you can create with the command:</p>
<pre><code class="language-sh">npx create-next-app &lt;your-app-name&gt;
</code></pre>
<h3 id="heading-overview">Overview</h3>
<p>The app will have two primary components:</p>
<ul>
<li><strong>The serverless endpoint</strong> - receives POST requests and broadcasts them via WebSocket</li>
<li><strong>The client</strong> - React app that handles all the fun UI and receives broadcasts via WebSocket</li>
</ul>
<h3 id="heading-types">Types</h3>
<p>Before we create either of the primary components, let&#8217;s go ahead and create a <code>types.ts</code> file in the root of our project where we can share any types that both the server and the client will need.</p>
<p>The order of actually creating something is totally different from the way I&#8217;ll present it here, of course: in the course of normal development, you discover common types as you&#8217;re working, not at the outset.</p>
<pre><code class="language-typescript">// /types.ts
export type Message = {
username: string;
date: Date;
text: string;
// Adding a type will let us display notifications from the server about
// connections and such differently than regular messages from users
type: &quot;message&quot; | &quot;notification&quot;;
};
</code></pre>
<h3 id="heading-serverless-endpoint">Serverless Endpoint</h3>
<p>Creating these endpoints is really easy with deployment services like Vercel and Netlify, but it&#8217;s worth noting that there&#8217;s nothing that magical about what they&#8217;re doing. It wouldn&#8217;t be that much harder to create a lambda in AWS to do the same thing. But they package things so that all you have to do is create a file in the right directory in your project, and &#8211;bam&#8211; you&#8217;ve got a locally testable, immediately deployable function. Can we pause to appreciate, for a moment, just how easy that is?</p>
<pre><code class="language-typescript">// /pages/api/sendMessage.ts
import { NextApiRequest, NextApiResponse } from &quot;next&quot;;
import { Message } from &quot;../../types&quot;;
export default async (req: NextApiRequest, res: NextApiResponse) =&gt; {
const { username, message } = req.body;
if (req.method === &quot;POST&quot; &amp;&amp; typeof username &amp;&amp; typeof message === &quot;string&quot;) {
try {
await broadcastMessage({ username: username, text: message });
// You could send more information here if you want, but all that&#39;s really important is telling the client that their submission was successful
res.status(200);
} catch (error) {
res.status(500).json({ error });
}
} else {
res.status(400).json({ error: &quot;Bad request&quot; });
}
};
// Function to broadcast a message to all connected clients
type BroadcastOptions = Partial&lt;Message&gt; &amp; {
date?: Date;
type?: &quot;message&quot; | &quot;notification&quot;;
};
export const broadcastMessage = async (
message: Message,
) =&gt; {
const defaultOptions = {
date: new Date(),
type: &quot;message&quot;,
};
message = Object.assign({}, defaultOptions, message);
// Do something to publish our message
};
</code></pre>
<p>The NextJS request/response typing is obviously platform specific, but there&#8217;s nothing else that&#8217;s particularly special here. We have a function that grabs some information off a POST body and calls a <code>broadcastMessage</code> function devoted to broadcasting a message that doesn&#8217;t do anything yet.</p>
<h3 id="heading-the-client">The Client</h3>
<p>I&#8217;m going to try to keep the client code as simple as possible while still separating the different aspects of our front end into components in a way that will make it easier to update and customize them down the road.</p>
<p>We&#8217;ll have four files here:</p>
<ul>
<li><a href="#heading-page"><code>/src/app/page.tsx</code></a>: the parent page for our chat app</li>
<li><a href="#heading-username"><code>/src/app/Username.tsx</code></a>: the component that lets a user put in a username and then connect to chat</li>
<li><a href="#heading-chat-box"><code>/src/app/ChatBox.tsx</code></a>: the component that displays chat messages</li>
<li><a href="#heading-chat-input"><code>/src/app/ChatInput.tsx</code></a>: the component that lets users type in and submit messages</li>
</ul>
<p>I&#8217;ve stripped off any <a href="https://tailwindcss.com/">Tailwind</a> classes or styling of any kind in an attempt to keep this as simple as possible.</p>
<h3 id="heading-a-brief-aside-about-an-important-component-pattern">A Brief Aside About an Important Component Pattern</h3>
<p>Please note an important pattern, however: every one of my three components accepts an optional incoming className as well as any attributes that are valid for the element they will return. When thinking about component reusability, it&#8217;s very important to separate what classes and attributes are essential to the component, wherever it&#8217;s located, from classes and attributes that are specific to a particular invocation of a component.</p>
<p>To make our component flexible and reusable, especially when using something like Tailwind that uses a ton of classes, we do the following:</p>
<ol>
<li><strong>Destructure Essential Props</strong>: We pull out essential props like <code>foo</code> and <code>className</code> from the incoming props. The <code>className</code> is optional and defaults to an empty string if not provided.</li>
</ol>
<pre><code class="language-typescript">const MyComponent = ({ foo, className = &#39;&#39;, ...rest}: MyComponentProps &amp; React.HTMLProps&lt;HTMLDivElement&gt;) =&gt; {
// ...
}
</code></pre>
<ol start="2">
<li><p><strong>Collect the Rest:</strong> Any props that are not explicitly destructured are collected into a rest variable using the rest syntax (<code>...rest</code>). This ensures that any additional props passed to our component are not lost.</p>
</li>
<li><p><strong>Spread Attributes:</strong> Finally, we use the spread syntax (<code>{...rest}</code>) to add these remaining props to our component. This ensures that our component can accept any HTML attributes or custom attributes without having to specify them ahead of time.</p>
</li>
<li><p><strong>Combine Class Names:</strong> The <code>className</code> prop is special. We concatenate any incoming <code>className</code> with our component&#8217;s default classes. This allows external customization while maintaining essential styling.</p>
</li>
</ol>
<p>Here&#8217;s all of that put together:</p>
<pre><code class="language-typescript">// Boilerplate component that can set its own classes and attributes while also
// accepting both, optionally, from the outside
type MyComponentProps = {
foo: string,
className?: string
}
const MyComponent = ({ foo, className = &#39;&#39;, ...rest}: MyComponentProps &amp; React.HTMLProps&lt;HTMLDivElement&gt;) =&gt; {
return (
&lt;div {...rest} className={`${className}`}&gt;
&lt;h1&gt;{foo}&lt;/h1&gt;
&lt;/div&gt;
)
}
</code></pre>
<h3 id="heading-page">Page</h3>
<p>We&#8217;ll keep all of our app&#8217;s state at this level.</p>
<pre><code class="language-typescript">// /src/app/page.tsx
// In Vercel, you need to declare your first component that&#39;s not able to be rendered on the server
&quot;use client&quot;;
import { useEffect, useState, useCallback } from &quot;react&quot;;
import { Message } from &quot;../../types&quot;;
import Username from &quot;./Username&quot;;
import ChatBox from &quot;./ChatBox&quot;;
import ChatInput from &quot;./ChatInput&quot;;
export default function Home() {
const [messages, setMessages] = useState([] as Message[]);
const [username, setUsername] = useState(&quot;&quot;);
const addMessage = (message: Message) =&gt; {
setMessages((prevMessages) =&gt;
[...prevMessages, message]
// limit the history length by only ever keeping the most recent 50
// messages, at most
.splice(-50)
);
};
const sendMessage = useCallback(
async (text: string) =&gt; {
await fetch(&quot;/api/sendMessage&quot;, {
method: &quot;POST&quot;,
headers: {
&quot;Content-Type&quot;: &quot;application/json&quot;,
},
body: JSON.stringify({ username, message: text }),
});
},
[username]
);
return (
&lt;main&gt;
&lt;h1&gt;Serverless Chat&lt;/h1&gt;
{!username &amp;&amp; &lt;Username setUsername={setUsername} /&gt;}
{username &amp;&amp; (
&lt;&gt;
&lt;ChatBox messages={messages} /&gt;
&lt;ChatInput submit={sendMessage} /&gt;
&lt;/&gt;
)}
&lt;/main&gt;
);
}
</code></pre>
<h3 id="heading-username">Username</h3>
<pre><code class="language-typescript">// /src/app/Username.tsx
import { Dispatch, SetStateAction, useState } from &quot;react&quot;;
type UsernameProps = {
setUsername: Dispatch&lt;SetStateAction&lt;string&gt;&gt;;
className?: string;
};
const Username = ({
setUsername,
className = &quot;&quot;,
...rest
}: UsernameProps &amp; React.HTMLProps&lt;HTMLDivElement&gt;) =&gt; {
const [text, setText] = useState(&quot;&quot;);
const handleKeyDown = (event: React.KeyboardEvent&lt;HTMLInputElement&gt;) =&gt; {
// Let the user submit their username by hitting the enter key
if (event.key === &quot;Enter&quot; &amp;&amp; text.length &gt; 0) {
setUsername(text);
}
};
return (
&lt;div {...rest}&gt;
&lt;label&gt;
Username:
&lt;input
autoFocus={true}
type=&quot;text&quot;
name=&quot;name&quot;
value={text}
onChange={(event) =&gt; setText(event.target.value)}
onKeyDown={handleKeyDown}
placeholder=&quot;username&quot;
/&gt;
&lt;input
type=&quot;submit&quot;
value=&quot;Connect β‘οΈ&quot;
disabled={!text}
onClick={() =&gt; setUsername(text)}
/&gt;
&lt;/label&gt;
&lt;/div&gt;
);
};
export default Username;
</code></pre>
<h3 id="heading-chat-box">Chat Box</h3>
<pre><code class="language-typescript">// /src/app/ChatBox.tsx
import { useEffect, useRef } from &quot;react&quot;;
import { Message } from &quot;../../types&quot;;
// this external dependency needs to be included in your package.json
// It just lets us format dates more easily than with JavaScript&#39;s somewhat
// boroque date formatting methods
import dayjs from &quot;dayjs&quot;;
type ChatBoxProps = {
messages: Message[];
};
const ChatBox = ({
messages,
...rest
}: ChatBoxProps &amp; React.HTMLProps&lt;HTMLUListElement&gt;) =&gt; {
// create a ref to the messages container
const messagesContainer = useRef&lt;HTMLUListElement&gt;(null);
// Keep the chat scrolled to the bottom whenever there is an incoming message
useEffect(() =&gt; {
const messagesContainerRef = messagesContainer.current as HTMLUListElement;
if (messagesContainerRef) {
messagesContainerRef.scrollTop = messagesContainerRef.scrollHeight;
}
}, [messages]);
return (
&lt;ul ref={messagesContainer} {...rest}&gt;
{messages.map(({ username, date, text, type }, i) =&gt; (
&lt;li key={i}&gt;
&lt;span&gt;
{dayjs(date).format(&quot;HH:mm:ss&quot;)}
{username.toLowerCase() === &quot;server&quot; &amp;&amp; &quot;:&quot;}
&lt;/span&gt;
{username.toLowerCase() !== &quot;server&quot; &amp;&amp; (
&lt;span&gt;
{username}:
&lt;/span&gt;
)}
&lt;span&gt;
{text}
&lt;/span&gt;
&lt;/li&gt;
))}
&lt;/ul&gt;
);
};
export default ChatBox;
</code></pre>
<h3 id="heading-chat-input">Chat Input</h3>
<pre><code class="language-typescript">// /src/app/ChatInput.tsx
import { ReactHTMLElement, useCallback, useState } from &quot;react&quot;;
type ChatInputProps = {
submit: (text: string) =&gt; void;
};
const ChatInput = ({
submit,
className = &quot;&quot;,
...rest
}: ChatInputProps &amp; React.HTMLProps&lt;HTMLDivElement&gt;) =&gt; {
const [text, setText] = useState(&quot;&quot;);
const handleKeyDown = (event: React.KeyboardEvent&lt;HTMLInputElement&gt;) =&gt; {
if (event.key === &quot;Enter&quot; &amp;&amp; text.length &gt; 0) {
sendMessage(text);
}
};
const sendMessage = useCallback(
(text: string) =&gt; {
submit(text);
setText(&quot;&quot;);
},
[submit]
);
return (
&lt;div {...rest} className={`${className}`}&gt;
&lt;input
autoFocus={true}
type=&quot;text&quot;
value={text}
onChange={(event) =&gt; setText(event.target.value)}
onKeyDown={handleKeyDown}
placeholder=&quot;Your message&quot;
/&gt;
&lt;input
type=&quot;button&quot;
value=&quot;Send&quot;
onClick={() =&gt; sendMessage(text)}
disabled={!text}
/&gt;
&lt;/div&gt;
);
};
export default ChatInput;
</code></pre>
<h2 id="heading-adding-ably">Adding Ably</h2>
<h3 id="heading-sign-up-and-create-an-app">Sign Up and Create an App</h3>
<p>Head on over to <a href="https://ably.com/">Ably</a> and create an account, then click &#8220;Create New App&#8221; on your dashboard. Give your app a name, then select &#8220;Just Exploring&#8221; as your use case (it doesn&#8217;t really matter in this case).</p>
<p>You&#8217;ll then have a page with the header &#8220;Get started with your app&#8221; that should have your API key available for you to copy. This is your app-specific password for using Ably&#8217;s service from your webpage.</p>
<p>Add it to your <code>.env</code> file with the name <code>ABLY_API_KEY</code>. (You can, of course, choose whatever you want for the name. You&#8217;ll just need to use the same name when retrieving it later.) </p>
<pre><code class="language-sh">ABLY_API_KEY=&lt;your api key&gt;
</code></pre>
<p>Take a moment to make sure that your git repo, if you have one, has <code>.env</code> added to its <code>.gitignore</code> file so that you don&#8217;t unintentionally commit it to your repo. Your <code>.env</code> file is intended to be able to hold API Keys and other secrets that your app needs. When you deploy to Vercel, Netlify, or any other environment, they all have a way of setting environment variables there so that your app can use them while keeping them private.</p>
<h3 id="heading-install-the-ably-package-from-npm">Install the Ably package from npm</h3>
<p>Ably has a single package that will work on the server and the client.</p>
<pre><code>npm i ably
</code></pre>
<h3 id="heading-modify-the-serverless-function">Modify the Serverless Function</h3>
<p>Next, we&#8217;ll add a few things to <code>sendMessage.ts</code>:</p>
<pre><code class="language-typescript">import Ably from &quot;ably&quot;;
</code></pre>
<pre><code class="language-typescript">// Get our API key from our environment variables
const {
ABLY_API_KEY = &quot;&quot;,
} = process.env;
</code></pre>
<p>Following the <a href="https://www.npmjs.com/package/ably">documentation for Ably&#8217;s npm package</a>, we will add the following functionity to the <code>broadcastMessage</code> function:</p>
<ol>
<li>Connect to Ably using our API key</li>
<li>Get a specific channel</li>
<li>Publish our message to that channel</li>
</ol>
<pre><code class="language-typescript">const ably = new Ably.Realtime.Promise(
ABLY_API_KEY,
);
await ably.connection.once(&quot;connected&quot;);
// Get the channel we want to use -- note: we could name our channel anything
const channel = ably.channels.get(&quot;chat&quot;);
channel.publish(&quot;message&quot;, message);
</code></pre>
<p>Here is the entire file with these changes in context:</p>
<pre><code class="language-typescript">// /pages/api/sendMessage.ts
import { NextApiRequest, NextApiResponse } from &quot;next&quot;;
import { Message } from &quot;../../types&quot;;
import Ably from &quot;ably&quot;;
export default async (req: NextApiRequest, res: NextApiResponse) =&gt; {
const { username, message } = req.body;
if (req.method === &quot;POST&quot; &amp;&amp; typeof username &amp;&amp; typeof message === &quot;string&quot;) {
try {
await broadcastMessage({ username: username, text: message });
res.status(200).json({ status: `Message sent: ${username}: ${message}` });
} catch (error) {
res.status(500).json({ error });
}
} else {
res.status(400).json({ error: &quot;Bad request&quot; });
}
};
const {
ABLY_API_KEY = &quot;&quot;,
} = process.env;
type BroadcastOptions = Partial&lt;Message&gt; &amp; {
date?: Date;
type?: &quot;message&quot; | &quot;notification&quot;;
};
// Function to broadcast a message to all connected clients
export const broadcastMessage = async (
message: BroadcastOptions,
) =&gt; {
const defaultOptions = {
date: new Date(),
type: &quot;message&quot;,
};
message = Object.assign({}, defaultOptions, message);
const ably = new Ably.Realtime.Promise(
ABLY_API_KEY,
);
await ably.connection.once(&quot;connected&quot;);
const channel = ably.channels.get(&quot;chat&quot;);
// Note: Ably gives you the ability to specify different kinds of events, so a
// channel can have all sorts of information traveling across it,
// differentiated by key name. Here, we&#39;ve just named the ones we&#39;re concerned
// about &quot;message&quot;. It could be anything.
channel.publish(&quot;message&quot;, message);
};
</code></pre>
<h3 id="heading-create-an-endpoint-to-hand-out-tokens">Create an Endpoint to Hand Out Tokens</h3>
<p>At this point, we need to add one small wrinkle. To subscribe to the same events we&#8217;re publishing to isn&#8217;t any more fundamentally complicated: the code we&#8217;ll add to our client in a bit is about as simple as what we did above. But there is an added concern with client-side code: how do we keep our API key secret?</p>
<p>An API Key is a permanent app password&#8211;and we can use it safely in our serverless function because a client doesn&#8217;t have to be able to see that code in order to execute it. Client-side code, however, is fundamentally different: we ship our code to the client&#8217;s browser, and they compile and execute it with a browser. Any secrets therein are not, therefore, secret.</p>
<p>To deal with this, Ably has, in addition to basic authentication&#8211;using an API key&#8211;<a href="https://ably.com/docs/auth">token authentication</a>, which allows us to hand out much more short-lived tokens with fine-grained access control. We&#8217;ll use the token the same way we would use an API key, we&#8217;ll just have to fetch it from a source that can create tokens using the original API key&#8211;in other words, a separate serverless function just for authentication.</p>
<pre><code class="language-typescript">// Note where you put your auth file -- you&#39;ll need to use the path
// in your client code.
//
// /pages/api/ably/auth.ts
import { NextApiRequest, NextApiResponse } from &quot;next&quot;;
import Ably from &quot;ably/promises&quot;;
const { ABLY_API_KEY = &quot;&quot; } = process.env;
const rest = new Ably.Rest(ABLY_API_KEY);
export default async (req: NextApiRequest, res: NextApiResponse) =&gt; {
const tokenParams = {
// This is just a unique identifier
clientId: &quot;chat-client&quot;,
};
try {
const tokenRequest = await rest.auth.createTokenRequest(tokenParams);
res.setHeader(&quot;Content-Type&quot;, &quot;application/json&quot;);
res.send(JSON.stringify(tokenRequest));
} catch (err) {
res.status(500).send(&quot;Error requesting token: &quot; + JSON.stringify(err));
}
};
</code></pre>
<p>All this does is hand out tokens.</p>
<h3 id="heading-modify-the-client">Modify the Client</h3>
<p>Now we can circle back to our client-side React code and add the Ably SDK.</p>
<p>As before, we&#8217;re going to import Ably:</p>
<pre><code class="language-typescript">import Ably from &quot;ably&quot;;
</code></pre>
<p>Then, inside the primary component in <code>/src/app/page.tsx</code>, we&#8217;re going to create a state variable to keep track of the channel the client will use.</p>
<pre><code class="language-typescript">const [channel, setChannel] =
useState&lt;Ably.Types.RealtimeChannelPromise | null&gt;(null);
</code></pre>
<p>Now we need to add a function that will be run just once, when the component initializes. The hooks convention for doing that is a <code>useEffect</code> hook with an empty array as its dependency.</p>
<pre><code class="language-typescript">useEffect(() =&gt; {
// this code just runs once, when the component initializes, because of the
// empty array passed in for its dependencies
return () =&gt; {
// the function returns a function that will be run when a component is
// unmounted, so that you can do any necessary cleanup
}
}, [])
</code></pre>
<p>Lets also head off a problem before it happens, and make it possible to use an async function in our <code>useEffect</code>, as we&#8217;ll be doing things like waiting for connections and other fundamentally asynchronous concerns.</p>
<p>Unfortunately, you can&#8217;t make the <code>useEffect</code> function itself <code>async</code> because React expects the return of <code>useEffect</code> to either be <code>undefined</code> or a function for cleanup. If <code>useEffect</code> was async, it would return a <code>Promise</code>, and that would confuse React&#8217;s internals.</p>
<p>But there&#8217;s a clever workaround: define an async function <em>within</em> the <code>useEffect</code>, and then immediately call it. This works with React&#8217;s expectations while allowing us to use async functionality.</p>
<pre><code class="language-typescript">useEffect(() =&gt; {
const init = async () =&gt; {
// you can use await in here
}
init();
return () =&gt; {
// cleanup
}
}, [])
</code></pre>
<p>Now, inside the init function we&#8217;re going to create, we will:</p>
<ol>
<li>Create an Ably client object, and tell is to use the path to the auth URL we created with our auth function to get tokens</li>
<li>Wait for a connection message from it</li>
<li>Send a message to our users saying they&#8217;re connected to chat</li>
<li>Get the specific channel we want to use &#8211; it just has to be the same as the one we specified in <code>sendMessage.ts</code></li>
<li>Subscribe to messages on that channel, and call our internal <code>addMessage</code> method whenever messages come in</li>
</ol>
<p>One note: we&#8217;re getting a really nice feature when we use Ably&#8217;s client and pass it the path to our auth url: it handles all the token refreshes automatically for us. Since the key feature of tokens is that they are short-lived, you have to have logic somewhere that handles when they expire and fetches a new token. Ably&#8217;s client takes care of all of that for us.</p>
<pre><code class="language-typescript">useEffect(() =&gt; {
// Set this variable in the top-level scope so that we can use it to close the
// connection in our clean-up function
let ablyClient: Ably.Types.RealtimePromise;
const init = async () =&gt; {
// Create a new client, using the path to our serverless function that hands
// out tokens
ablyClient = new Ably.Realtime.Promise({
authUrl: &quot;/api/ably/auth&quot;,
});
await ablyClient.connection.once(&quot;connected&quot;);
// This is purely a nicety to show users that they have connected, it uses
// our own internal addMessage functionality and doesn&#39;t send anything to
// Ably
addMessage({
username: &quot;Server&quot;,
text: &quot;Connected to chat! β‘οΈ&quot;,
type: &quot;notification&quot;,
date: new Date(),
});
const chatChannel = ablyClient.channels.get(&quot;chat&quot;);
// Save a reference to the channel, BUT keep using the `chatChannel` variable in this function, since setChannel is NOT synchronous, and doesn&#39;t return a promise // CHECK THIS
setChannel(chatChannel);
// Incoming messages
// We&#39;ll be listening for &quot;message&quot;, which is the string we chose in
// `sendMessage.ts`, but just as a reminder, it could be anything.
await chatChannel.subscribe(&quot;message&quot;, (message: Ably.Types.Message) =&gt; {
addMessage(message.data as Message);
});
};
init();
// Cleanup function
return () =&gt; {
if (channel) {
channel.unsubscribe();
}
if (ablyClient) {
ablyClient.close();
}
};
}, []);
</code></pre>
<p>That&#8217;s all there is to it: this should be all you need to get a working chat proof of concept working using Ably as an external WebSockets provider.</p>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>This is only the simplest possible use of WebSockets &#8211; if you check out <a href="https://github.com/neagle/serverless-chat">my example</a>, you can see that I&#8217;ve taken an additional step and used Ably&#8217;s presence feature to keep track of who has entered or left the chat, so that a user could know who they&#8217;re talking to. There aren&#8217;t any fundamentally new concepts involved, just adding some new state to track connected users and subscribing to a few more events.</p>
<p>Similarly, it doesn&#8217;t take any new concepts to be able to add niceties like UI to show when someone is typing, for example: just send messages using a different key that indicate typing.</p>
<p>I don&#8217;t love having to add a third-party service to my stack, but I was impressed with Ably&#8217;s documentation and ease of implementation. Maybe it will convince you to give WebSockets a try on your serverless app&#8211;realtime features demo <em>great</em>. WebSockets can also keep you from having to poll endpoints for updates, which heads off potentially greater charges for your functions, since invocation count is one of the primary ways they are billed.</p>
<p>In summary, integrating Ably and WebSockets with your serverless deployments is pretty easy, and is has the potential for huge benefits for user features while saving you money.</p>
/posts/this-is-not-a-doorNate Eagle<img src="not-a-door.jpg" style="width: 100%; max-width: 600px; margin-bottom: 1em;" class="float-right" />
<p>A number of years ago, I worked in a small office at a startup in downtown Washington, DC. One day I came in to work to discover that the developers who worked in one section of the office had rearranged their desks. They had made a new configuration possible by treating one wall with a door in it as a solid wall, lining up several desks against it. But that did leave a problem: what should they do about the door?</p>
<p>Their solution was one that many people have used in the past: they put up a sign on the other side of the door that said &#8220;Do not use this door!&#8221; </p>
<h2 id="heading-signifiers-and-affordances">Signifiers and Affordances</h2>
<p>A sign is a kind of <a href="https://www.interaction-design.org/literature/topics/signifiers#:~:text=Signifiers%20are%20perceptible%20cues%20that,help%20people%20perform%20appropriate%20tasks.">signifier</a>: it gives users a cue as to how to use something. But signifiers&#8211;especially signs, or instructional text&#8211;can often be a sign that there is something wrong. It can mean that there is tension within an object between multiple signifiers, or between signifiers and <a href="https://www.interaction-design.org/literature/topics/affordances">affordances</a>.</p>
<img src="ceci-nest-pas-une-pipe.webp" style="margin-bottom: 1em;" class="float-right" />
<p>And in this case, that sign was really working against everything else about that door. There was a frame around it. It was inset from the wall. And it had a handle! Affordance is all about the relationship between a user and an object. In this case, that door had a handle, and that handle <em>afforded</em> turning to any human being with an arm and a hand approaching it. It was the most obvious thing about the door, and the sign that my coworkers put up had a tough job arguing that it didn&#8217;t matter <em>how</em> much that thing looked like a door, it <em>wasn&#8217;t</em>.</p>
<p>I dealt with this problem all the time when working with interfaces. A problem was identified with how users were interacting with something or were <em>trying</em> to interact with something, and the first, easiest suggestion was often to add instructional text. Just tell the user what to do next! This is almost never the best solution.</p>
<p>Users don&#8217;t want to spend their time reading. How often do you patiently read the instructions first thing when you buy something new? How often do you, instead, do your best to figure out how to assemble or use something on your own, based on its essential properties and your existing knowledge, only breaking out the instructions if that first approach fails?</p>
<p>It can reveal also a tension or lack of harmony in your interface: maybe your own interface is sending contradictory instructions. The user has to handle that tension by picking a winner, but it can lead to a significant diminution of ease and even pleasure in using your interface. &#8220;I managed to get that to work, despite their best efforts!&#8221; That&#8217;s not the thought you want your users to have about you.</p>
<h2 id="heading-just-take-off-the-handle">Just take off the handle</h2>
<div class="image-with-caption">
<img src="no-handle-door.jpg" style="max-height: 400px;" />
<div class="caption">It really ISN&#8217;T a door!</div>
</div>
<p>When I got in, I asked, &#8220;Is there a screwdriver handy?&#8221; And I removed the door&#8217;s handle. It&#8217;s a <em>little</em> funny to have a door without a handle in the wall, but it also removes the fundamental tension in the interface. That door no longer affords opening, so no one will try to open it. And the sign could be removed.</p>
<p>Any time you&#8217;re tempted to reach for instructional text in an interface, ask instead: could we remove or fix the attributes of our interface that miscommunicate how it&#8217;s intended to be used?</p>
/posts/how-to-study-how-i-passed-the-comp-tia-security-examinationNate Eagle<p>I was recently moved onto a new contract at work and told that I would need to get a level of access to interact with the client&#8217;s systems that would require three certifications, and one of them had to be <a href="https://www.comptia.org/certifications/security">CompTIA Security+</a>. I raised my eyebrows: that&#8217;s definitely outside my wheelhouse. I&#8217;ve been a &#8220;full-stack&#8221; engineer for the last four years at my present company, but the preponderance of my skills and experience are in design and front-end development.</p>
<p>I&#8217;d only had one certification before: <a href="https://aws.amazon.com/certification/certified-cloud-practitioner/">AWS Cloud Practitioner</a>, which I&#8217;d let expire a couple years ago. It&#8217;s a foundational, entry-level exam (in other words, it&#8217;s easier than all the others), and even it was a <em>reasonably</em> difficult exam that gave me a lot of useful context I wouldn&#8217;t otherwise have had on AWS&#8217;s ecosystem and its core organizing principles and value proposition.</p>
<p>The first thing I did was study for and re-take the Cloud Practitioner exam. I used <a href="https://www.pluralsight.com/cloud-guru">A Cloud Guru</a>, which I had used last time around and liked. They recently had a sale and I purchased a year&#8217;s-worth of personal access for a reasonable price.</p>
<p>My supervisor asked: how long would it take me to prepare for the Security+ exam? I researched some training options and, based on the hours of course instruction listed combined with homework and self-study, estimated four weeks. My supervisor thought that was reasonable. After going back to HR, though, they ended up coming back with a different plan: a <a href="https://trainingcamp.com/training/comptia-security-plus-certification-bootcamp/">Security+ Certification Boot Camp</a> that would run for <em>four days</em>. I could attend virtually, but it would be a classroom setting. It was expensive, but, of course, my employer has to pay me for the time I&#8217;m studying, so if it can really work on that timeline, it could be worth it for them. But four days? I gulped, said, &#8220;Sounds great!&#8221; and got ready for an interesting week.</p>
<p>On Friday, I took the test remotely and passed with a 775/950, with a minimum passing score of 750. I thought I&#8217;d take the time to reflect on my experience and write down some of lessons that I&#8217;m taking away.</p>
<h2 id="heading-whats-valuable-about-certifications">What&#8217;s valuable about certifications?</h2>
<p>Certifications have obvious benefits: some roles require them, so they can be connected to tangible increases in responsibility and compensation. And they can make you a more attractive hire. Part of the benefit comes from the fact that, in my experience, they&#8217;re not easy. Having them demonstrates real knowledge.</p>
<p>But I&#8217;m going to focus on another angle: they provide a unique and powerful form of education, <em>especially</em> for people without technical degrees, and for people with certain personalities.</p>
<p>I&#8217;m a liberal arts graduate: I went to <a href="https://www.sjc.edu/">a weird school in Annapolis, MD</a> where we studied great books for four years. I wouldn&#8217;t trade that experience for anything, and it hasn&#8217;t stopped me from having a great career in tech. But it does mean my expertise is sometimes more <em>narrow</em> than <em>broad</em>. I started making webpages on my own and never stopped: I know a ton about CSS, JavaScript, and all sorts of the details in making sites. But there are a lot of things I never got around to learning on my own. What <em>is</em> the difference between TCP and UDP? I get how private/public keys work in terms of my workflow, but do I really understand the chain of authority involved in the public key infrastructure (PKI) that makes encryption work?</p>
<p>I won&#8217;t run through my areas of ignorance exhaustively: my ego can&#8217;t take it. But my point is that there&#8217;s a lot that I think people with technical degrees got run through at least once in school that I&#8217;ve never really <em>needed</em> to know. And certifications, especially foundational certifications like the ones I&#8217;ve gotten so far, are a powerful way to get to know an entire landscape.</p>
<p>I also think some people are, by nature or habit, just a little better about figuring out the big picture on their own. Me? I&#8217;m more of a details guy, which works both for and against me. I do great when I get to sink my teeth into particular problems and work them with creativity, enthusiasm, and focus. But I&#8217;m sometimes a little too impatient: I&#8217;d rather start building that new feature than spend a day getting the lay of the land. If you&#8217;re at all like me, getting broad certification in some core technologies might help you understand the environment you work in better.</p>
<h2 id="heading-what-is-the-comptia-security-ce-cert">What is the CompTIA Security+ ce cert?</h2>
<p>The Security+ certification is a broad, foundational certification in IT Security. It&#8217;s &#8220;a mile wide and an inch deep,&#8221; which means it covers a lot of territory but doesn&#8217;t require deep expertise in any particular aspect of it. I tried to get a sense of its difficulty by poking around online, and the most accurate statement I read anywhere is that it just varies according to the individual. For an experienced Ops person, it might be pretty easy. I got the sense that for people like me, who don&#8217;t normally work most of the subjects covered, it&#8217;s considered reasonably difficult. Apparently about 50% of first-time takers fail.</p>
<p>I&#8217;m going to try to avoid dwelling too much on the specific aspects of Security+, though, and focus on the general take-aways I had about approaching studying for certifications and tests in general.</p>
<h2 id="heading-training-camps-boot-camp">Training Camp&#8217;s Boot Camp</h2>
<p>The format for my Boot Camp was three-and-a-half days of full-on instruction with a teacher in a real room somewhere with some in-person students and the rest of us attending via Zoom. It was supposed to be about eight hours of instruction, I think, but the reality was that we never ended before 6:30 or so. After that, we had at least 1 - 2 hours of homework assigned. The trainer&#8217;s advice? &#8220;Forget YouTube, forget all your normal stuff. Just eat, sleep, and breathe Security+ this week, pass the test, and pick back up where you left off.&#8221;</p>
<p>I&#8217;m a little suspicious about such a compressed format: I don&#8217;t know if that&#8217;s the best formula for long-term retention. However, I found that the quality of instruction and the techniques used were much higher quality than any previous instructor-led technical training I&#8217;d received.</p>
<h3 id="heading-pre-testing-and-review">Pre-Testing and Review</h3>
<p>The most important strategy from my perspective was the instructor&#8217;s use of <a href="https://www.techlearning.com/news/the-power-of-pretesting-why-and-how-to-implement-low-stakes-tests#:~:text=Implementing%20Pretesting%20in%20the%20Classroom,-Pan%20encourages%20educators&text=He%20adds%2C%20%E2%80%9CIt's%20a%20way,easy%20to%20implement%2C%20Pan%20says.">pre-testing</a> and <strong>review</strong>.</p>
<p>Each night we were assigned homework, which consisted of memorizing some information and completing questions from a practice test. The practice test was long, much longer than the actual exam, and the number of questions assigned ranged from 50 or so to well over 100. And every night, they asked us to do questions from sections we hadn&#8217;t covered yet. Why would they do that?</p>
<p>It&#8217;s a technique called pre-testing, and it engages students&#8217; brains in a powerful way by showing them that they <em>don&#8217;t</em> already know everything (something our brain likes to believe) and directing their attention to exactly <em>what</em> they don&#8217;t know. Rather than a general &#8220;how much do I think I know about encryption?&#8221; you get a whole lot of specific information about what things you <em>do</em> already know (I know about RSA vs elliptical encryption and why the latter is better, I know why special characters aren&#8217;t useful for password security) and what things I <em>don&#8217;t</em> already know. And you&#8217;d better believe that when I heard the answers to the questions that stumped me being discussed in the next day&#8217;s lesson, my brain was <em>ready</em> for them, and primed to pay attention and prioritize remembering those answers.</p>
<p>You can employ this technique on your own: look for quizzes or tests on a subject you want to learn <em>before</em> you spend time studying it. Can&#8217;t find anything? Ask <a href="https://chat.openai.com/">ChatGPT</a> to write one for you.</p>
<p>We also spent at least an hour, sometimes more, at the beginning of the day reviewing, in depth, every question we requested going over. And this was high-quality review: not telling us the correct answer (which was in the practice test, anyway), but going through the reasoning and details related to every possible answer. Because the questions selected came from us, the students, it took two important things into consideration: a) what we, ourselves, needed most help with, and b) what wasn&#8217;t covered as thoroughly or even effectively during the initial classroom discussion. No matter how good the instructor is, there will be parts they cover more or less effectively, and structuring review time this way allowed us to naturally direct time to the things we needed the most.</p>
<p>It can be hard, as a teacher (and I was one, for a year after college!), not to focus on your <em>lesson plan</em>, and that all-important list of what <em>you want to impart</em>. But you have to find ways to change that focus, because learning is not mostly about you, as a teacher: it&#8217;s about your students and what you can work with them to receive. Spending time on review questions is <em>not</em> time lost, with no items on the curriculum getting checked off: it was some of the most valuable instruction time we had.</p>
<h2 id="heading-diagrams-and-sketches">Diagrams and sketches</h2>
<p>During their explanations, the instructor often used a digital stylus to draw and type directly onto the slides. This, accompanying the verbal explanations, helped me remember more of the subjects they covered. I can still remember the color and position of some of the explanations. It&#8217;s very clear that it pulled in more of my attention than verbal explanations on their own would have. Humans are sensing creatures, and the more of those senses you can pull in to the act of communication, the more strands there are to help things stick.</p>
<p>This insight may be as old as blackboards, but it&#8217;s worth preserving the insight into the digital age of virtual teaching: maybe a Wacom tablet is a worthwhile investment if you&#8217;re spending much time at all training people virtually.</p>
<h2 id="heading-the-importance-of-writing">The importance of writing</h2>
<p>Similarly, the instructor recommended physically writing one&#8217;s notes. They said (and I&#8217;ve read this a number of places before) that evidence seems to show that writing notes by hand produces greater retention than typing. They said that making flash cards can also be worthwhile, though the reality is that <em>making</em> them is probably more beneficial than <em>using</em> them. So make them twice!</p>
<p>I&#8217;m going to confess that I didn&#8217;t take this advice: I love to type, and I type quickly, and I couldn&#8217;t convince myself to abandon it as a crutch. What I did do was use my <a href="https://help.obsidian.md/Plugins/Daily+notes">daily note in Obsidian</a> to take notes throughout, and tried as much as possible to put things in my own words rather than re-type what the instructor said. That forced me to reprocess the idea rather than simply repeating a sequence of words.</p>
<h2 id="heading-the-instructors-delivery-style">The instructor&#8217;s delivery style</h2>
<p>This is something I didn&#8217;t have much control over, but I just want to call it out for anyone that&#8217;s ever in the position of being a trainer. It&#8217;s not easy to listen to someone for days in a row and keep your mind engaged. But your trainer&#8217;s skills with projection, intonation, and use of examples can go a long way in helping you.</p>
<p>This instructor spoke loudly&#8211;sometimes comically so, to be honest&#8211;and really varied their intonation. They spoke with a lot of expression and intensity. And they also very frequently threw in vivid examples from their own career to illustrate different concepts. These examples were funny and sometimes shocking, but they were <em>enormously</em> helpful for remembering concepts. I&#8217;ll never forget the concept of <a href="https://www.cisa.gov/sites/default/files/publications/cisa-insights_chain-of-custody-and-ci-systems_508.pdf">chain of custody</a> in cyber security forensics, due to the story they told about how much work was thrown out the window one time when a case a friend of theirs had worked nine months on was irrevocably broken by one person who left some hard drives where they were temporarily unsupervised.</p>
<h2 id="heading-what-i-did-while-the-instructor-talked">What I did while the instructor talked</h2>
<p>I found that what I did while the instructor talked was one of the most crucial pieces of virtual learning. Sitting at my desk and paying attention and typing notes was okay, but there were a few problems with it: I got tired, and the temptation to check email / Twitter was always there. I&#8217;d ignore it five times, then cave on the sixth. I speculate that resisting that temptation takes a little bit of willpower every time, and that we have a finite supply of willpower. Getting comfortable somewhere I couldn&#8217;t touch my phone or anything else was worse: within half an hour I was ready to fall asleep.</p>
<p>By far the best approach for me turned out to be using Zoom on my phone, putting on my over-ear Bose headphones, and walking around the house doing minor chores. Empty the dishwasher, clean spots on the wall that I&#8217;d never noticed, move things back to the rooms they should be in; anything that didn&#8217;t really take much thought or attention. Being physically active let me focus on what was being discussed.</p>
<p>But what about notes, you ask! My experience was that notes were less valuable than paying full, steady attention. This class, like most classes, provided plenty of study materials. Why do I need to recreate my own? It was more valuable, in my opinion, to be fully present for the presentation and discussion of those topics by the instructor.</p>
<p>I think this is a core insight for a lot of life: get rid of the note-taking, the photo-taking, the record-making. Try to experience things as fully as possible. Even if you don&#8217;t remember everything that way, you give it the best chance to <em>change</em> you, and to change how you interact with those ideas later. It will make the time spent studying later more effective. It will make the time you spend enjoying the memories of that ride through Pirates of the Caribbean more pleasant.</p>
<h2 id="heading-study-with-chatgpt">Study with ChatGPT</h2>
<p>ChatGPT is changing the world shockingly quickly. Right now, we&#8217;re limited more by what it occurs to us to ask of it than by its own powers. One of the many, many things it does well is act as a study partner.</p>
<p>The first night, I asked it:</p>
<blockquote>
<p>Good evening! I&#8217;m doing some homework for this evening, and we&#8217;re supposed to memorize some of the most important port numbers and what protocols they&#8217;re matched to. This is the list we&#8217;ve been given. Could you help me memorize these? I&#8217;ll give you the list, and then I&#8217;d like you to randomly ask me about the msg format and protocol for different ports from the list. When you tell me whether I was right or wrong, I&#8217;d love it if you wanted to drop in any small bits of trivia or context that you think would help them stick in my head. Ready? Here&#8217;s the list:</p>
<p>22 TCP -> SSH -> also used by SCP/SFTP<br>23 TCP -> Telnet -> plaintext cli<br>53 UDP -> DNS<br>69 UDP -> TFTP (trivial) file transfer protocol, small files only, no directory browsing<br>80 TCP -> HTTP<br>443 TCP -> HTTPS<br>445 TCP -> SMB, Server Message Block, windows file sharing over tcp</p>
</blockquote>
<p>And it <a href="https://chat.openai.com/share/8997d55f-0a1c-4b0f-8b50-6b26bcc0b3cd">worked great</a>.</p>
<h2 id="heading-whats-next">What&#8217;s next?</h2>
<p>I have to get one more certification soon for work, and I&#8217;m not sure what it will be, yet. After that, I think the next step for me, personally, would be to get the <a href="https://aws.amazon.com/certification/certified-developer-associate/">AWS Certified Developer - Associate</a> certification, using <a href="https://www.pluralsight.com/cloud-guru/courses/aws-certified-developer-associate-dva-c02">A Cloud Guru&#8217;s course</a>. Wish me luck!</p>
/posts/how-to-add-comments-to-a-static-site-using-github-pull-requestsNate Eagle<p>There are a lot of advantages to building a website statically, which means compiling it once and serving up the results to the visitors, rather than generating a site <em>dynamically</em>, which means programmatically generating the contents of a site in response to individual requests. For content that doesn&#8217;t change, there&#8217;s obvious efficiency: build once, serve many times. From your server&#8217;s perspective, it gets to serve up a site like it&#8217;s 1995: all files! And with the CDNs and edge locations that are part of the modern infrastructure of the web, visitors can receive that static content incredibly quickly from a location near them no matter where they are on the globe.</p>
<p>As a developer, static sites are appealing for even more reasons: writing and manipulating content just involves working with files, which I can pull open in the IDE I use for my regular coding work. They can also be relatively evergreen: as the tech world puts out new CMSes and new frameworks at a pretty rapid clip, building a site from a collection of files can work the same way for years and years. <a href="https://github.com/neagle/n3">This website is statically generated</a>, via custom JavaScript code so that I only need to update it in response to changes in my own needs, but I use almost the same system for organizing source files that I first picked up from <a href="">Jekyll</a>, a ruby-powered static site generator that was the first tool of its kind I used.</p>
<p>There are a ton of great static site generators out there. <a href="https://www.gatsbyjs.com/">Gatsby</a> is probably the most high-octane, and it&#8217;s great for a production site (especially since its acquisition by Netlify), as it has had a ton of development put into its power features. (One of my favorite Gatsby evangelists is <a href="https://queen.raae.codes/">Queen Raae</a>.) But if you&#8217;re working on a site for yourself, why not give writing your own a try? You&#8217;ll probably learn some things, and you&#8217;ll have the pleasure of having something that works <em>exactly the way you like it</em>.</p>
<h2 id="heading-what-happens-when-you-want-comments-on-your-static-site">What happens when you want comments on your static site?</h2>
<p>The first place statically generated sites run into trouble is with user interactions. If you want your site to collect user data (like emails) or allow interactive features (like commenting), you have an interesting problem to solve.</p>
<p>For forms, a straightforward answer is to use some kind of a compute service to receive data and perform an action in response to it, whether it&#8217;s emailing you (like with a contact form) or saving data in a database. These days, there are a lot of services for that like <a href="https://www.netlify.com/products/forms/">Netlify Forms</a> or Vercel&#8217;s <a href="https://vercel.com/integrations/formspree">Formspree</a>.</p>
<p>It would be possible to treat comments in a similar fashion:</p>
<ol>
<li>Have the user submit comments to an endpoint and get saved to a db</li>
<li>Trigger a rebuild of the site (or of the specific page)</li>
<li>Have the site&#8217;s build script hit an endpoint to get any relevant comments from the db at build time</li>
</ol>
<p>There&#8217;s nothing wrong with this approach, but for my personal site it was a bit unappealing. As soon as you add a database to your site&#8217;s tech stack, you&#8217;ve moved away from the earlier file-based simplicity of a statically generated site. Now you&#8217;re tied to a specific service with data that has to have its durability separately managed and, if necessary, migrated. The site is no longer fully self-contained within its git repository.</p>
<p>I wanted a solution where comments would live along with entries themselves as files.</p>
<p>This is how I thought about what I needed to do:</p>
<ol>
<li>Have the user submit comments to an endpoint</li>
<li>Have that endpoint create a file with the comment in my repo</li>
<li>Trigger a rebuild of the site</li>
<li>Update my site&#8217;s build script to read files in a comment folder, if it exists, for every entry</li>
</ol>
<h2 id="heading-building-an-endpoint-to-receive-comment-submissions">Building an Endpoint to Receive Comment Submissions</h2>
<p>In order to have users submit their comments, we need a permanent URL (an endpoint) and a server that can react to what it receives. It&#8217;s a perfect use for functions as a service (FaaS), where we let someone else manage the server and the runtime and pay for the compute time we actually use. For a personal site, a use like this will almost certainly fall well within whatever their free tier&#8217;s limit happens to be. AWS Lambda, for instance, allows one <em>million</em> free requests per month. (And 3.2 million seconds of compute time, though requests are usually more relevant for situations like this.) AWS Lambda even offers <a href="https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html">URLs for functions</a>, so you don&#8217;t need to mess with API gateways if you don&#8217;t want to.</p>
<p>But many modern deploy services offer their own packaged versions of functions as a service that come with some niceties. (The cost is usually a lower free tier and a higher cost than you would pay if you used AWS Lambda directly.) Netlify, which I use to deploy this site, offers <a href="https://www.netlify.com/products/functions/">Netlify functions</a>, and the most relevant benefit of using them for me is that you can put the code for those functions in a folder as files in your repo. That means that the compute functions I use for dynamic features of my site sit alongside the files and code that power everything else.</p>
<p>For my personal site, simplicity is tremendously important: I want to have fun maintaining and working with this site and I don&#8217;t want to spend much time dealing with the kinds of complexities I deal with in my regular job. Netlify functions <em>do</em> come with a <a href="https://www.netlify.com/products/functions/">much lower threshold</a> for the free tier: 125,000 requests per month and 100 hours of compute time. But it should still easily accomodate the needs of my humble site.</p>
<p>Functions are appealingly simple: they handle a request and return a response. In the middle, you do all the things that make your function special.</p>
<h2 id="heading-creating-a-serverless-function">Creating a Serverless Function</h2>
<p>Here&#8217;s a boilerplate for a Netlify function using Typescript:</p>
<pre><code class="language-typescript">// Import types
import { Handler, HandlerEvent } from &#39;@netlify/functions&#39;;
export const handler: Handler = (event: HandlerEvent) =&gt; {
// The event&#39;s body is where all the content you&#39;re interested in is
const body = JSON.parse(event.body);
return {
statusCode: 200,
body: JSON.stringify({
message: &#39;Heard you loud and clear!&#39;,
}),
};
};
</code></pre>
<p>You can put this in a file called <code>comment.ts</code> in a folder called <code>netlify/functions/</code> off the root of your site. (Note: you can <a href="https://docs.netlify.com/configure-builds/file-based-configuration/#functions">customize the location of your functions</a> via a <code>netlify.toml</code> file.)</p>
<p>Another benefit of using Netlify functions is that they&#8217;re very easy to test locally using the <a href="https://docs.netlify.com/cli/get-started/">Netlify CLI</a>. Fire up <code>netlify dev</code> in your project root, and it should tell you, among other things, which functions it has loaded:</p>
<pre><code>Loaded function comment http://localhost:8888/.netlify/functions/comment.
</code></pre>
<p>Since we&#8217;re not handling any data coming from a post request yet, you can just pull up that URL in your browser to test it as a GET request. You should get a response like this:</p>
<pre><code>// 20230722150049
// http://localhost:8888/.netlify/functions/test
{
&quot;message&quot;: &quot;Heard you loud and clear!&quot;
}
</code></pre>
<p>This setup makes it really easy to test and debug our code as we figure out how to take the next steps in actually responding to incoming requests.</p>
<h2 id="heading-where-to-put-the-actual-comment-files">Where to Put the Actual Comment Files</h2>
<p>Where should you put your actual comment files? This step in the process will depend on your own setup and preferences. For my site, every post has its own folder and an <code>index.md</code> with its content. This gets built to an <code>index.html</code> file, which makes it possible to have URLs without filenames or extensions in them (<code>nateeagle.com/posts/my-hypothetical-post/</code>). For comments, I decided to add a <code>comments</code> folder in the particular post folder, and have every comment have a filename constructed of epoch time (<code>+new Date()</code>), which both gives us filenames that are easy to sort by date and ensures uniqueness, plus the slugified name of the commenter. (Slugification ensures that whatever the person put in the Name field for their comment, the filename receives only characters that are valid for a filename.)</p>
<p>How you parse those files and turn them into data that can be acted upon in your post template will be unique to your static site generator.</p>
<h2 id="heading-creating-a-comment-file-via-githubs-rest-api">Creating a Comment File via GitHub&#8217;s REST API</h2>
<p>To create the comment file using our serverless function, we will use the <a href="https://docs.github.com/en/rest">GitHub REST API</a> to perform some tasks that are similar to how we&#8217;d add a file ourselves.</p>
<ol>
<li>Create a new branch.</li>
<li>Get the data we want from the comment form submission (commenter name, email, content) and use it to create a comment file for the post in question.</li>
<li>Commit that file.</li>
<li>Create a pull request for the new branch.</li>
</ol>
<p>I recommend getting this workflow working completely independently of your serverless function at the start. Debugging multiple, interleaved concerns can be frustrating and time-consuming: when you have the ability to manipulate and examine something independently, it&#8217;s easy to understand and to fix issues that come up. <a href="https://www.postman.com/">Postman</a> has been my go-to tool for a long time for working with APIs.</p>
<p>The first thing we&#8217;ll need to interact with the GitHub API is a <a href="https://docs.netlify.com/configure-builds/environment-variables/">personal access token (PAT)</a>, which is essentially a password that lets your code act on your behalf. We&#8217;ll store the PAT as an environment variable so that it never gets committed as part of our code. Locally, we can store the value in a <code>.env</code> file, and then we&#8217;ll store it in Netlify&#8217;s environment variables interface for the public build.</p>
<p>GitHub has tucked the <a href="https://docs.netlify.com/configure-builds/environment-variables/">personal access tokens admin</a> in a slightly hard-to-find location. From anywhere in GitHub, you can click on your profile image -> <a href="https://github.com/settings/profile">Settings</a> -> <a href="https://github.com/settings/apps">Developer Settings</a> -> <a href="https://github.com/settings/tokens">Personal Access Tokens</a>.</p>
<p>Here are the actual steps we&#8217;ll need to follow to use the API to create a comment PR. For all of these calls, you&#8217;ll need to <a href="https://docs.github.com/en/rest/overview/authenticating-to-the-rest-api">send an authorization header</a>.</p>
<ol>
<li><a href="https://docs.github.com/en/rest/git/refs">Get the SHA of the latest commit from the <code>main</code> branch.</a></li>
</ol>
<pre><code>GET https://api.github.com/repos/:owner/:repo/git/refs/heads/main
</code></pre>
<ol start="2">
<li><a href="https://docs.github.com/en/rest/guides/using-the-rest-api-to-interact-with-your-git-database">Create a new branch</a>:</li>
</ol>
<pre><code>POST https://api.github.com/repos/:owner/:repo/git/refs
</code></pre>
<pre><code class="language-json">body: {
&quot;ref&quot;: &quot;refs/heads/new-branch-name&quot;,
&quot;sha&quot;: &quot;the-commit-sha-from-the-previous-call&quot;,
}
</code></pre>
<ol start="3">
<li><a href="https://docs.github.com/en/rest/repos/contents#create-or-update-file-contents">Create a new comment file</a>:</li>
</ol>
<pre><code>PUT https://api.github.com/repos/:owner/:repo/contents/path/to/your/file.txt
</code></pre>
<pre><code class="language-json">body: {
&quot;message&quot;: &quot;your commit message&quot;,
&quot;branch&quot;: &quot;new-branch-name&quot;,
&quot;content&quot;: &quot;base64-encoded-file-content&quot;,
}
</code></pre>
<p>Please note a few things: the path in the API URL determines where your actual file goes, and after <code>contents</code> it proceeds from the root of your git repository. Your actual file content needs to be base64 encoded&#8211;this is a bit like packing your file in a safe shipping container that ensures nothing in its content can get interfered with (or interfere with anything else!) on its way to its destination.</p>
<p>In our function we can use <code>Buffer.from(newComment).toString(&#39;base64&#39;)</code> to encode our content, but for testing purposes you can use the command line:</p>
<pre><code class="language-sh"># Outputs: dGVzdCE=
echo -n &#39;test!&#39; | base64
</code></pre>
<ol start="4">
<li><a href="https://docs.github.com/en/rest/pulls/pulls#create-a-pull-request">Open a PR</a>:</li>
</ol>
<pre><code>POST https://api.github.com/repos/:owner/:repo/pulls
</code></pre>
<pre><code class="language-json">body: {
&quot;title&quot;: &quot;Pull request title&quot;,
&quot;body&quot;: &quot;So-and-so wants to add a comment&quot;,
&quot;head&quot;: &quot;new-branch-name&quot;,
&quot;base&quot;: &quot;main&quot;, // the branch you want to merge the PR into
}
</code></pre>
<p>Once we get all of these working in Postman or your API-testing tool of choice, we&#8217;re ready to implement these calls in our serverless function.</p>
<h2 id="heading-an-aside-about-spam">An Aside About SPAM</h2>
<p>Any public-facing comment system has to worry about SPAM comments. Creating Pull Requests for comments means that nothing can get published to the site without being manually merged in, but it would be nice to avoid having to manually delete a lot of bot-created PRs, if possible.</p>
<p>I&#8217;ve added a simple honeypot technique that will filter out at least some automated SPAM by adding a hidden field that will cause our comment function to reject the comment if there&#8217;s any content inside it.</p>
<h2 id="heading-implementing-a-comment-function">Implementing a Comment Function</h2>
<p>Without further ado, here is my implemented comment function:</p>
<pre><code class="language-typescript">import slugify from &#39;@sindresorhus/slugify&#39;;
import dayjs from &#39;dayjs@1.11.7&#39;;
import fetch from &#39;node-fetch&#39;;
import { Handler, HandlerEvent } from &#39;@netlify/functions&#39;;
const DEBUG = false;
const API_URL = &#39;https://api.github.com&#39;;
const REPO_URL = &#39;/repos/neagle/n3&#39;;
// This is a convenience method that lets us turn on/off verbose console logging
// with a single argument
function log(...args: any[]) {
if (DEBUG) {
console.log(...args);
}
}
async function getLatestCommitSHA(headers: Record&lt;string, string&gt;) {
const response = await fetch(`${API_URL}${REPO_URL}/git/refs/heads/main`, {
method: &#39;GET&#39;,
headers,
});
const data = await response.json();
log(&#39;getLatestCommitSHA&#39;, data);
return data.object.sha;
}
async function createNewBranch(
headers: Record&lt;string, string&gt;,
headSha: string,
newBranchName: string,
) {
const response = await fetch(`${API_URL}${REPO_URL}/git/refs`, {
method: &#39;POST&#39;,
headers,
body: JSON.stringify({
ref: `refs/heads/${newBranchName}`,
sha: headSha,
}),
});
const data = await response.json();
log(&#39;createNewBranch&#39;, data);
return data;
}
async function createNewComment(
headers: Record&lt;string, string&gt;,
name: string,
email: string,
text: string,
postSlug: string,
newBranchName: string,
) {
// Construct a valid filename for the new comment
const slugifiedCommenterName = slugify(name, {
lowercase: true,
});
const commentSlug = `${+new Date()}-${slugifiedCommenterName}`;
const commentPath =
`src/content/posts/${postSlug}/comments/${commentSlug}.md`;
// Construct the new comment
const newComment = [
&#39;---&#39;,
`name: ${name}`,
`email: ${email}`,
`date: ${dayjs().format(&#39;YYYY-MM-DD HH:mm:ss&#39;)}`,
&#39;---&#39;,
text,
].join(&#39;\n&#39;);
// Create new comment file
const response = await fetch(
`${API_URL}${REPO_URL}/contents/${commentPath}`,
{
method: &#39;PUT&#39;,
headers,
body: JSON.stringify({
message: `Add new comment from ${name}`,
branch: newBranchName,
content: Buffer.from(newComment).toString(&#39;base64&#39;),
}),
},
);
const data = await response.json();
log(&#39;createNewComment&#39;, data);
return { data, newComment };
}
async function createPullRequest(
headers: Record&lt;string, string&gt;,
name: string,
newBranchName: string,
newComment: string,
postTitle: string,
postSlug: string,
) {
const response = await fetch(
`${API_URL}/repos/neagle/n3/pulls`,
{
method: &#39;POST&#39;,
headers,
body: JSON.stringify({
title: `Add new comment on &quot;${postTitle}&quot; from ${name}`,
body:
`${name} wants to add a new comment on [${postTitle}](https://nateeagle.com/posts/${postSlug}).\n\n\`\`\`\n${newComment}\n\`\`\``,
head: newBranchName,
base: &#39;main&#39;,
}),
},
);
return await response.json();
}
export const handler: Handler = async (event: HandlerEvent) =&gt; {
const accessToken = process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
const body = JSON.parse(event.body);
const { name, email, text, website, postTitle, postSlug } = body;
// Use a honey trap to prevent spam
if (website) {
return {
statusCode: 422,
body: JSON.stringify({ message: &#39;No website links allowed&#39; }),
};
}
if (!postSlug) {
return {
statusCode: 422,
body: JSON.stringify({ message: &#39;Missing postSlug&#39; }),
};
}
try {
const headers = {
Authorization: `Bearer ${accessToken}`,
};
// Get the SHA of the latest commit
const headSha = await getLatestCommitSHA(headers);
// Create a new branch for the comment
const newBranchName = `new-comment-${+new Date()}`;
await createNewBranch(
headers,
headSha,
newBranchName,
);
const { newComment } = await createNewComment(
headers,
name,
email,
text,
postSlug,
newBranchName,
);
const newPullRequest = await createPullRequest(
headers,
name,
newBranchName,
newComment,
postTitle,
postSlug,
);
const pullRequestUrl = newPullRequest?.html_url || &#39;&#39;;
return {
statusCode: 200,
body: JSON.stringify({
message: `Comment added!`,
pullRequestUrl,
}),
};
} catch (err) {
return {
statusCode: 500,
body: JSON.stringify({ message: `Error adding comment: ${err}` }),
};
}
};
</code></pre>
<p>Note that this requires that an environment variable of <code>GITHUB_PERSONAL_ACCESS_TOKEN</code> be set.</p>
<p>Also note that any packages you use in your Netlify functions need to be added in a package.json file at the root of your project. For my function, I need to <code>npm add @sindresorhus/slugify dayjs node-fetch</code>.</p>
<p>In my actual comment form, there is an input labeled &#8220;website&#8221; that is hidden via CSS. This will only filter out naΓ―ve SPAM bots, but it&#8217;s a place to start.</p>
<h2 id="heading-getting-notifications-for-new-comments">Getting Notifications for New Comments</h2>
<p>Now, any time a PR is submitted, I get an email notification via Github&#8217;s own notification system. If it&#8217;s important to have more timely notifications, though, it would be very possible to hook in to a service like Slack or Twilio to send yourself a Slack message or even an SMS notification.</p>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>For me, this is a particularly satisfying solution for a development blog. It builds on the strengths of a statically generated site in keeping all content organized as files, including the serverless function we use to make commenting possible. Static sites have proven their value over time, and modern services like FaaS are making it easier to add dynamic functionality.</p>
<p>Let me know if you&#8217;ve implemented something similar or chosen a different solution to the same problem. I&#8217;d love to see other approaches.</p>
/posts/the-mai-kai-black-magicNate Eagle<p>Original source: <a href="http://www.slammie.com/atomicgrog/blog/2012/04/04/mai-kai-cocktail-review-the-black-magic-emerges-from-the-shadows-as-a-true-classic/">Hurricane Hayward</a></p>
<p>The Black Magic is one of the most famous cocktails to come out of the legendary <a href="https://www.maikai.com/">Mai-Kai</a> in Fort Lauderdale, Florida. My wife ordered one our first time there, and its appearance isn&#8217;t necessarily the most impressive: it looks a bit like milky iced coffee, though it comes in a fishbowl-sized snifter. I remember the drink being good, but it wasn&#8217;t until I tried <a href="http://www.slammie.com/atomicgrog/blog/">Hurricane Hayward</a>&#8217;s tribute recipe during the pandemic that I fell in love.</p>
<p>Hayward, who lives in Florida, has compiled an invaluable <a href="http://www.slammie.com/atomicgrog/blog/mai-kai-cocktail-guide/mai-kai-cocktail-recipes/">Mai-Kai cocktail guide</a> that is useful for exploring the menu, but its most remarkable treasure is the collection of tribute recipes he has put together: attempts to reverse-engineer what the Mai-Kai delivers out of its mysterious kitchen. I haven&#8217;t made all of them, but I&#8217;ve made this one countless times, and I have a worry that I&#8217;ll confide to you right now: it might be better than the real thing.</p>
<p>I think part of the reason is that fundamental advantage of the home bartender: you can easily use top-shelf spirits. Amazingly, Hurricane Hayward himself rates the drink a mere 3.5 out of 5, a review I find incomprehensible, but who am I to question the taste buds of a man with his accomplishments?</p>
<p>Hurricane Hayward made a virtual guest appearance on a pandemic-era episode of <a href="https://www.youtube.com/watch?v=xBPP_sWnlcU">Spike&#8217;s Breezeway Cocktail Hour</a> that&#8217;s well worth watching.</p>
<p>I&#8217;ve split out the ingredients of Mariano&#8217;s Mix #7 and Donn&#8217;s Spices #2 to make it easier to scale this recipe up.</p>
/posts/before-midnightNate Eagle<p>The cocktail that got me into cocktails was the Aviation, and though I was able to get a lovely Crème de Violette from Rothman & Winter back in the early teens, I was intrigued by several recipes I found that listed <a href="https://cremeyvette.com/">Crème Yvette</a> as an alternative ingredient.</p>
<p>Outward appearances showed an ingredient that seemed up Tempus Fugit&#8217;s alley, ripe for reintroduction to a market with an appreciation for classic cocktails, so I ordered two bottles through a local liquor store. Fast-forward more years than I care to count: I still haven&#8217;t finished that second bottle.</p>
<p>The first problem: they decided to eschew artificial ingredients, and the lack of dye leaves this liqueur a lovely, raspberry red. Let me list a handful of the cocktail names that call for Yvette: Submarine Kiss, Stratosphere, Blue Moon, and, of course, The Aviation. The bluish purple of this ingredient is as essential to the drinks that used it as the unmistakable red of Campari.</p>
<p>The liqueur&#8217;s flavor is mostly berries, unlike the floral crème de violette, which, whatever that says about me and the cocktail canon I know best, is neither as interesting nor as useful. But it&#8217;s not that it tastes bad: so this drink is my most successful attempt to use it.</p>
<p>Inspired by Valentine&#8217;s Day chocolate, it&#8217;s an attempt to fold chocolate and berries together in the mold of a classic cocktail, with velvet texture from the egg white and lemon juice to provide the requisite tartness. For the gin, I recommend a class London Dry, or something with an edge to it. (The truth is that Beefeater does just fine.) For the crème de cacao, I am noticing more and more fancy bottles of things that look like they&#8217;re worth trying: it&#8217;s hard for me, personally, to use anything other than Tempus Fugit.</p>
<p>If you don&#8217;t <em>have</em> Crème Yvette&#8211;and this post isn&#8217;t exactly an attempt to get you to run out and buy it&#8211;it should be a good template for any berry liqueur you might be interested in finding uses for. Maybe Chambord works well here? I should really try it, too, with crème de violette itself.</p>
<p>The name comes from my favorite installment of the Richard Linklater trilogy with Julie Delpy and Ethan Hawke.</p>
/posts/stowawayNate Eagle<p>This is a non-alcoholic version of the <a href="http://www.slammie.com/atomicgrog/blog/2012/04/13/mai-kai-cocktail-review-the-mutiny-is-a-worthy-foe-in-the-battle-of-the-tropical-titans/">Mutiny</a>, one of the great cocktails by Mariano Licudine, the founding bartender of the Mai-Kai. I made it on one particular evening for two individuals of the underage persuasion, but it&#8217;s not oversweet and would, I think, delight any adult who wanted a zero-abv alternative.</p>
<h3 id="heading-rich-honey">Rich Honey</h3>
<p>Rich honey is just a 2:1 honey-to-water syrup. Add one part water to two parts honey and heat it up enough to combine. It turns honey from an unmixable gloop into a well-behaved cocktail ingredient. Mariano Licudini was a particular fan of the rich honey ratio and used it in a lot of his creations at the Mai-Kai.</p>
<h3 id="heading-syrups">Syrups</h3>
<p>The syrups and spices are the only challenging ingredients to find here. BG Reynolds has good versions of the passionfruit syrup and fassionola, but I don&#8217;t think they have a current version of Don&#8217;s Spices #2. You might have to go ahead and <a href="https://thelosttikilounge.com/ingredients/dons-secret-recipes/">make your own</a>, which isn&#8217;t the end of the world.</p>
<ul>
<li><a href="https://bgreynolds.com/products/passion-fruit-tropical-cocktail-syrup?variant=16057991692401">Passionfruit Syrup</a></li>
<li><a href="https://bgreynolds.com/products/red-fassionola">Fassionola</a></li>
</ul>
/posts/captains-bloodNate Eagle<p>Original Source: <a href="https://www.drinkboy.com/cocktails/Recipe.aspx?itemid=36">Drinkboy</a></p>
<p>There are a number of different versions of this recipe floating around, but this is the one I tried first. I need to give <a href="https://cocktailswithsuderman.substack.com/p/happy-hour-2-ways-to-make-a-captains">Peter Suderman&#8217;s preferred version</a> a shot sometime, as he argues that the core requirement of this cocktail is the use of falernum. I love falernum and am loath to question my betters, but I&#8217;m a big fan of the version listed above from Robert Hess.</p>
<p>A daiquiri is one of those rare things in life describable as perfect. But for all that, there&#8217;s an unstable equilibrium to it for this drinker, at least, as it really wants to tip over into a version of itself that uses aged rum instead of light rum. (Not that light rum can&#8217;t be aged&#8230; I&#8217;m not going to get into that right now.)</p>
<p>This version of Captain&#8217;s Blood swaps in an aged rum, preferably Jamaican, and adds two dashes of Angostura. (And adjusts the proportions a little bit.) And, for me, becomes a little more than perfect: it becomes a drink I fall in love with. It&#8217;s a great way to feature Jamaican rum (<a href="https://shop.twojames.com/two-james-doctor-bird-rum.html">Doctor Bird</a> is a favorite of mine for this cocktail) and a showpiece for the power of Angostura.</p>
<p>I&#8217;m a little unclear on the origin of the name, but there is a movie with the similar-sounding name of <a href="https://www.imdb.com/title/tt0026174/">Captain Blood</a>, starring Errol Flynn and Olivia de Havilland, that may or may not have anything to do with this. If you decide you want to be a real fan of this cocktail and watch it, though, your time will be very well spent.</p>
/posts/elements-spellerNate Eagle<p>My son&#8217;s been kinda obsessed, recently, with what words can be spelled using element abbreviations. I suggested writing a program that could check whether any given string could be spelled, and then had to immediately try it myself because I wasn&#8217;t sure, at first, how to do it. The main trick is in handling the possibility that using a one-letter element abbreviation where a two-letter element abbreviation is also possible could make an otherwise possible word look impossible (<code>frog</code>, for example).</p>
<p>I was able to come up with a solution using recursion that was pretty fun. Click &#8220;result&#8221; in the CodePen below if you want to see the code in action.</p>
<p class="codepen" data-height="600" data-default-tab="js" data-slug-hash="oNMQaYK" data-user="neagle" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
<span>See the Pen <a href="https://codepen.io/neagle/pen/oNMQaYK">
Spell with Elements</a> by Nate Eagle (<a href="https://codepen.io/neagle">@neagle</a>)
on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>
/posts/eagles-dreamNate Eagle<p>Original source: <a href="https://www.youtube.com/watch?v=T8GjQA2N-q8">Robert Hess</a></p>
<p>Aside from the obvious personal appeal of this cocktail&#8217;s name, it is the second-best use for crème de violette I&#8217;ve found so far. And if you&#8217;re going to buy a bottle to use for <a href="/posts/aviation.html">Aviations</a> and then consume it ¼ oz at a time, you&#8217;re going to start looking around for more uses of that ingredient.</p>
<p>(Single-use liqueurs are the bane of my existence. I&#8217;ve dealt with that in a sub-optimal fashion, and have a bar <em>filled</em> with bottles. At least spirits will last forever on your shelf: lower-proof stuff goes flat over time, and there&#8217;s only so much room in one&#8217;s refrigerator.)</p>
<p>I ordered this cocktail once from <a href="https://www.thevarnishbar.com/">The Varnish</a>, after checking with the waiter whether weird orders were welcome, and was perfectly happy. It&#8217;s got a lovely color, a cloudlike texture, and a delicate sweetness.</p>
<p>The <a href="https://www.amazon.com/Protein-Stainless-Blender-Replacement-Drinking/dp/B08NXDBFWD">shaker ball</a> has become ubiquitous in the time since Robert Hess recorded his video: it is the perfect tool for augmenting a dry shake.</p>
/posts/burns-supperNate Eagle<p>My family got into celebrating <a href="https://en.wikipedia.org/wiki/Burns_supper">Burns Supper</a> when our neighborhood gastropub, <a href="https://www.thequeenvicdc.com/">the Queen Vic</a>, offered various holiday meals during the pandemic. We read the Wikipedia page while eating, that first year, and improvised some speeches and sang Auld Lang Syne, got a little more serious the second year, and then this year devoted quite a bit of time and preparation.</p>
<h2 id="heading-preparation">Preparation</h2>
<ul>
<li><a href="https://www.youtube.com/watch?v=9BD0SmdfVxc">How to Host a Burns Supper</a></li>
<li><a href="https://www.amazon.com/Life-Robert-Burns-Author-Catherine/dp/B010B9YRI0">The Life of Robert Burns</a>, by Catherine Carswell. I downloaded the Kindle edition and hurriedly read it the weekend before. Seems like a great biography, though it is famously controversial due to its &#8220;warts and all&#8221; depiction of Burns with a frankness that had apparently not been similarly true of previous efforts.</li>
<li><a href="https://docs.google.com/document/d/1EzR9I6WWiTCiOmkGbuy7TnxcZb1iKLYztzxxpoV04zU/edit?usp=sharing">Our program.</a> This wasn&#8217;t given to guests, it was just where we kept track of our plans.</li>
</ul>
<h2 id="heading-address-to-a-haggis">Address to a Haggis</h2>
<ul>
<li><a href="https://www.youtube.com/watch?v=qJSjAGVV6Zg">Address to a Haggis read aloud by David Sibbald:</a> This rendition is a good reference on pronunciation, and has better-than-average interpretation. I&#8217;m not a huge fan of a lot of the other live examples I found, which can be a little weak in how much they convey their understanding of the text.</li>
<li><a href="https://www.scottish-country-dancing-dictionary.com/to-a-haggis.html">Translation & &#8220;Free&#8221; Translation:</a> I like the idea of the free translation, with some humor, presented here. I used it as a reference to compose <a href="https://docs.google.com/document/d/1oZNrKMi5Py79c4_121alj12uDZRcO6eiPzVxGmKgHBo/edit?usp=sharing">a personalized version</a> (which I recommend doing) along with my ten-year-old son, which he read after each stanza. I think for people who are doing Burns Night for the first time, some help with the Scots is useful.</li>
<li>If you have time, memorize the poem! It frees you up to have a lot of fun presenting it. There are some traditional actions that go along with the recitation, like cutting open the haggis at the right moment, pantomiming weak and under-developed haggis-haters, &c, and all of that&#8217;s easier without paper in your hand.</li>
<li><a href="https://www.youtube.com/watch?v=HXm8JdC4k4c">Shipping up to Boston / Enter Sandman</a> cover by the Goddesses of Bagpipes, which we used to pipe in the haggis. Obviously not the most traditional option, but my opinion is that Rabbie would have approved.</li>
<li>I had a thirteen-year-old guest play two roles during the poem: first, I had him burp on cue at the line, &#8220;The old guidman, maist like to rive&#8211;&#8221; because I know virtually every thirteen-year-old boy prides himself on his ability to burp on command. Second, I had him stand up for the &#8220;But mark the rustic, haggis-fed!&#8221; part and act the part of a mighty Scots warrior. I handed him a toy sword for &#8220;clap in his walie nieve a blade&#8221; and had him pretend to cut off my leg, arm, and head at the appropriate parts in the line following. He did great.</li>
</ul>
<h2 id="heading-toast-to-the-immortal-memory">Toast to the Immortal Memory</h2>
<ul>
<li><a href="https://www.youtube.com/watch?v=XrAQNragW00">Toast to the Immortal Memory advice</a></li>
<li><a href="https://docs.google.com/document/d/1vf6OSd98uLO01MV0tXRzQPshp3FqwtEAAcNvmu6mIrc/edit?usp=sharing">My toast</a></li>
</ul>
<h2 id="heading-toast-to-the-lassies">Toast to the Lassies</h2>
<ul>
<li><a href="https://docs.google.com/document/d/102wWjZ3QkO5PLHq5NqkxPmZ_VM5TtiNWJkSvwrRreCk/edit?usp=sharing">Toast to the Lassies</a>. Composed and delivered by my friend Conrad. He completed it in advance and provided it to Vickie, who composed and delivered the response toast to the laddies.</li>
</ul>
<h2 id="heading-toast-to-the-laddies">Toast to the Laddies</h2>
<ul>
<li><a href="https://docs.google.com/document/d/1GVabA0fkopAkAZWzBAwoQpS0tQtfjAx7/edit?usp=sharing&ouid=116386697573263181696&rtpof=true&sd=true">Toast to the Laddies</a>. Composed and delivered by my mother-in-law Vickie.</li>
</ul>
<h2 id="heading-auld-lang-syne">Auld Lang Syne</h2>
<ul>
<li><a href="https://docs.google.com/document/d/1Hx5PJkEZiCcUttPIcbJCB9WgYqjWogIGU47hXGK0r44/edit?usp=sharing">Lyrics printout with some translation</a>. If you&#8217;re not going to do the Scots version, why even bother? My son helped point out all the words that he would appreciate having translated. They are placed beneath the original text so that it&#8217;s easy to get some helpful context while singing.</li>
</ul>
<h2 id="heading-food">Food</h2>
<ul>
<li><a href="https://cooking.nytimes.com/recipes/1022930-cock-a-leekie-soup-scottish-chicken-and-leek-soup">Cock-a-Leekie Soup:</a> Maybe my favorite food from the night. Rebekah said the only hard part of making this recipe was finding barley: she ultimately found it in the international aisle at Safeway.</li>
<li><a href="https://www.bbcgoodfood.com/recipes/neeps-tatties">Neeps and Tatties</a></li>
<li><a href="https://thetakeout.com/does-haggis-taste-good-recipe-scotland-robert-burns-1840839371">Whisky cream sauce:</a> There are a bunch of recipes on the linked page: you&#8217;ll have to scroll down a bit for the whisky cream sauce.</li>
<li><a href="https://www.scottishgourmetusa.com/product/presentation-haggis-for-Burns-Night">Haggis:</a> Didn&#8217;t even bother to investigate making my own. This one, from Scottish Gourmet USA, was <em>really</em> tasty: lots of people I wouldn&#8217;t have expected asked for seconds. That said, we have a bunch left over (we had eleven people, two of them kids), and haggis definitely doesn&#8217;t taste as good the day after. (That distinctive organ-meat smell is more noticeable.) So it might be worth it to order a smaller one. But this one sure looked great during the presentation.</li>
<li><a href="https://cooking.nytimes.com/recipes/146-green-beans-with-ginger-and-garlic">Green Beans:</a> Burns Supper tradition basically calls for &#8220;a vegetable.&#8221;</li>
<li><a href="https://onbetterliving.com/wprm_print/11234">Cranachan:</a> Delicious. Note the need to start the oats soaking in whisky the night before.</li>
</ul>
<p>Rebekah handled all the food except for the haggis and cranachan.</p>
<h2 id="heading-whisky">Whisky</h2>
<p>We had these whiskies on offer:</p>
<ul>
<li><a href="https://scotchwhisky.com/whiskypedia/2323/black-bottle/">Gordon Graham&#8217;s Black Bottle</a> (blended)</li>
<li><a href="https://www.totalwine.com/spirits/scotch/blended-scotch/monkey-shoulder-scotch-whisky/p/127093750">Monkey Shoulder</a> (blended)</li>
<li><a href="https://www.douglaslaing.com/collections/timorous-beastie/products/timorous-beastie">Timorous Beastie</a> (blended)</li>
<li><a href="https://www.ardbeg.com/en-US/our-whiskies/ardbeg-wee-beastie">Ardbeg Wee Beastie</a> (single malt)</li>
<li><a href="https://kilkerran.scot/our-whisky/">Kilkerran Heavily Peated</a> (single malt)</li>
</ul>
<p>Judging by the bottle levels after the event, the Kilkerran Heavily Peated was the clear favorite. &#8220;Now that&#8217;s what a scotch should taste like,&#8221; one attendee said. For me, the Ardbeg Wee Beastie was a very close second. My friend Josh brought the Ardbeg and said it had been specifically recommended as suitable for Burns Suppers (the name certainly lends credence to that intention).</p>
<p>I wasn&#8217;t a big fan of the Timorous Beastie, but I&#8217;m a single malt fan, so that&#8217;s not a huge surprise. I mainly bought it because of the obvious Burns marketing.</p>
<h2 id="heading-poems">Poems</h2>
<p>This year, we read:</p>
<ul>
<li><a href="https://www.poetryfoundation.org/poems/43816/to-a-mouse-56d222ab36e33">To a Mouse</a></li>
<li><a href="https://www.poetryfoundation.org/poems/43797/address-to-the-devil">Address to the Deil:</a> A great podcast episode about the poem: <a href="https://www.youtube.com/watch?v=Pnn6lZ8Dk_U">Ear Read This</a></li>
<li><a href="https://www.poetryfoundation.org/poems/43815/tam-o-shanter">Tam o&#8217; Shanter:</a> Podcast episode: <a href="https://www.youtube.com/watch?v=LpyEBQBGVIA">Ear Read This</a>, Fun video version: <a href="https://www.youtube.com/watch?v=GAqVwCa_x5o">Tam o&#8217; Shanter: The Comic</a></li>
<li><a href="/posts/the-haggis-song.html">The Haggis Song:</a> This contribution came from my friend Josh Lee, who worked with ChatGPT to generate a song based on the style of Robert Burns.</li>
</ul>
<p>&#8230;and some other, non-Burns poetry. In general, the reader would stop between stanzas or even between phrases to explain certain words or phrases, which I think helps folks who aren&#8217;t familiar with the poems.</p>
<p>All of the poems benefit from spending some time learning the Scots vocabulary beforehand.</p>
/posts/aviationNate Eagle<h2 id="heading-notes">Notes</h2>
<p>This is one of the <em>very</em> few instances in which I don&#8217;t love the version of a cocktail on <a href="https://tuxedono2.com/aviation-cocktail-recipe">Tuxedo no.&nbsp;2</a>. It&#8217;s too sweet.</p>
<p>I prefer the version Robert Hess uses in <a href="https://www.youtube.com/watch?v=bwufKaNzNUA">this video</a> from the series he did for the Small Screen network. (It&#8217;s probably not a coincidence: this is the first version I learned to make.) The videos are a bit of a blast from the past, especially in their reminder that there was a time where it was not particularly easy to find crème de violette. But I fell in love with cocktails through Hess&#8217;s gentle, charming, accessible videos and they will forever have a place in my heart.</p>
<p>For gin, my favorite choice here might be good old Beefeater. I don&#8217;t much like <a href="https://www.aviationgin.com/">Aviation</a> gin in this cocktail, despite its name, as it has a soft quality that leaves me unsatisfied. I like a sharp edge to my Aviation. Most traditional London Dry gins should work.</p>
/posts/london-punch-house-punchNate Eagle<p>Original source: <a href="https://www.thedailybeast.com/whats-the-ultimate-holiday-party-drink-cognac-punch">David Wondrich</a></p>
<h2 id="heading-directions">Directions</h2>
<p><em>Step 1:</em> At least 24 hours before you intend to serve the punch, make a giant block of ice by filling a tupperware or other container with water and freezing it. Ideal size is one quart.</p>
<p><em>Step 2:</em> Also at least a day before, create the magic ingredient for this whole style of punch: <a href="https://www.saveur.com/how-to-make-oleo-saccharum/">oleo saccharum</a> (dog latin for &#8220;oily sugar&#8221;). Peel four lemons in unbroken spirals: the unbrokenness is mostly for style points. It&#8217;s also much easier to peel fresh lemons, as the oil in lemon peels dries out over time. Put the peels in a mason jar with 6 oz (ΒΎ cup) of white sugar. Seal the jar, shake it to cover the peels with sugar, then leave it overnight.</p>
<p>Don&#8217;t refrigerate it. The peels will appear to <em>melt</em> as the sugar draws out the oil them. If you pop open the lid for a second and take a sniff, it will smell delicious.</p>
<p><em>Step 3:</em> Two hours before you serve the punch, unseal the mason jar, add 6 oz (ΒΎ cup) fresh-squeezed, strained lemon juice, reseal and shake until all the sugar has dissolved. Now put it in the refrigerator.</p>
<p><em>Step 4:</em> Assemble the punch.</p>
<ul>
<li>Put your ice block in a one-gallon punch bowl. (If you have any trouble getting the block out of its container, just briefly run the bottom of the container under hot water.) Shake the contents of the mason jar and pour it into the punch bowl unstrained, peels and all.</li>
<li>Add 20 oz (2 and Β½ cups) VSOP Cognac and 6 oz (ΒΎ cup) Jamaican rum. Stir.</li>
<li>Add 1 quart (4 cups) cold water. Stir again and grate nutmeg on top.</li>
<li>Using a punch ladle, drape a few ends of the spiral lemon peels over the rim of the bowl.</li>
<li>Grate nutmeg over the top.</li>
</ul>
<h2 id="heading-notes">Notes</h2>
<p>I&#8217;ve made this a number of times over the years, and I even went to a cocktail class at the 2022 <a href="https://www.thehukilau.com/">Hukilau</a> where David Wondrich himself taught how to make it. The educational value for me was a little less than for the other attendees, but I still really enjoyed getting to meet him. I&#8217;m posting it here and adding my notes because you never know when an article on the internet&#8217;s going to disappear or go behind a paywall.</p>
<p>The VSOP Cognac should be a good one&#8211;I like Pierre Ferrand Cognac 1840 Original Formula, which seems tailor-made for something like this. But don&#8217;t make the mistake of asking your average liquor store attendant for a recommendation, since the moment you mention &#8220;punch&#8221; they&#8217;ll recommend garbage. The public&#8217;s perception is that punch is unsophisticated frat-party fare. This, obviously, is a different beast.</p>
<p>For the rum, I&#8217;ve never tried anything other than Smith & Cross. It&#8217;s reasonably priced, widely available, and absolutely delicious. But you&#8217;ll want some pot-stilled option with a ton of flavor.</p>
<p>If you like making punches, you&#8217;ll want a good punch bowl. I picked a beautiful, interesting looking brass punch bowl off of eBay. (It looks like <a href="https://laurelleaffarm.com/brass-punch-set.htm">this one</a>.) It&#8217;s impressive, but it has also soaked up hours and hours of my life in polishing and cleaning, and it also shows every bump it&#8217;s ever taken as a permament dent. I wouldn&#8217;t buy it again. But it&#8217;s hard to find glass punch bowls that don&#8217;t look like they belong at a church potluck.</p>
<p>Also, if you make a lot of punches and/or tiki drinks, you&#8217;ll probably want a <a href="https://www.cocktailkingdom.com/wondrich-ashley-nutmeg-grater">really nice nutmeg grater someday</a>.</p>
/posts/a-high-wind-in-jamaicaNate Eagle<h2 id="heading-notes">Notes</h2>
<p>I don&#8217;t spend a lot of my time inventing new cocktails: I like the classics, and appreciate how hard it is to contribute meaningfully to the existing canon. But I&#8217;ve come back over and over again to this riff on a rum old fashioned that I created to bring together two of my favorite things: Jamaica&#8217;s national rum, Wray & Nephew White Overproof, and crème de banane.</p>
<p>We are currently blessed with two tremendous versions of the latter: <a href="https://www.giffard.com/en/liqueurs-premium/373-3037.html">Banane du Brésil</a> and Tempus Fugit&#8217;s <a href="https://www.tempusfugitspirits.com/copy-of-creme-de-cacao">Crème de Banane</a>. They&#8217;re different and each is worth having. That means we need more cocktails that use them, so I pulled a basic <a href="https://www.tastingtable.com/1101587/what-it-means-to-mr-potato-head-a-cocktail/">Mr. Potato Head</a> on an old fashioned: overproof rum for the rye, crème de banane for the sugar/simple syrup, and tiki bitters for angostura.</p>
<p>It would be in the spirit of things to use other interesting Jamaican overproofs, including aged ones. A non-Jamaican rum would be another drink entirely.</p>
<p>It&#8217;s named after <a href="https://en.wikipedia.org/wiki/A_High_Wind_in_Jamaica_(novel)">the 1929 novel by Richard Hughes</a>, which is one of the most disturbing books I&#8217;ve ever read. I can&#8217;t recommend it highly enough.</p>
/posts/tom-and-jerryNate Eagle<p>Author: <a href="https://en.wikipedia.org/wiki/Audrey_Saunders">Audrey Saunders</a>. This <a href="https://twitter.com/audreysaunders/status/1339246079594946575?lang=en">Twitter thread</a> from 2020 has the recipe broken out into steps with a ton of helpful photos.</p>
<p>Original Source: <a href="https://drinkboy.com/pdf/TomAndJerry.pdf">drinkboy.com/pdf/TomAndJerry.pdf</a></p>
<h2 id="heading-directions">Directions</h2>
<h3 id="heading-preparing-the-batter">Preparing the Batter</h3>
<ul>
<li>Run egg whites through a food processor until stiff. Transfer to a bowl.</li>
<li>Run egg yolks until through a food processor until they are thin as water.</li>
<li>Then add sugar, spices, rum & vanilla to egg yolks (while food processor is<br>running).</li>
<li>Add egg whites back into egg yolk mixture and whisk together. Store inside a<br>sterilized container in refrigerator.</li>
</ul>
<h3 id="heading-to-serve">To Serve</h3>
<ul>
<li>Add 2 oz. of batter in a preheated, (10 oz) Irish coffee mug.</li>
<li>Add 1 oz aΓ±ejo rum + 1 oz cognac.</li>
<li>Fill with 6 oz boiling milk, briskly stirring with a short whisk (optimal) or spoon while adding milk, so that the two ingredients are beaten together.</li>
<li>Dust with freshly grated nutmeg.</li>
</ul>
<p>&#8211;Audrey Saunders</p>
<h2 id="heading-nates-notes">Nate&#8217;s Notes</h2>
<p>The Tom and Jerry is one of my favorite yearly traditions: traditionally, it&#8217;s not supposed to be made until the first snow has fallen, at which point the season is open. Let me tell you: seeing those first flakes start to fall and realizing that it&#8217;s time to make Tom and Jerries is a great feeling.</p>
<p>As with many of my recipes here, I&#8217;m posting this here so that it doesn&#8217;t disappear from the internet and leave me stranded: I&#8217;ve lost a few recipes I really love over the years, and it sucks. If there is any concern that I&#8217;m stepping on Ms. Saunders&#8217; toes by archiving this here, please let me know!</p>
<p>&#8211;Nate</p>
<h2 id="heading-ms-saunderss-notes-and-tips">Ms. Saunders&#8217;s Notes and Tips</h2>
<p><em>A Hot, Comforting, Old-World Delight Made With Milk, Raw Eggs, Rum, Cognac & Christmas Spices, and lots of love</em></p>
<p>I&#8217;ve been making Tom & Jerry&#8217;s for over 20yrs now&#8211; when I worked with Dale DeGroff at Blackbird back in 1999, he listed the Tom & Jerry on our menu that Christmas. He placed a beautiful porcelain bowl on the back bar, and I can distinctly remember asking him about what a Tom & Jerry was. The rest is history. Iβve served it at every bar Iβve managed ever since then.</p>
<h3 id="heading-tips">TIPS:</h3>
<p>Everyone is so worried about messing the recipe up, whipping the egg whites just so, maintaining the loft of the batter, adding cream of tartar to help stabilize it, etc. Forget the cream of tartar: what I&#8217;ve come to discover is that the batter doesn&#8217;t need to be stable at allβit turns out the batter is infinitely easier to work with once it collapses.</p>
<p>When T&J batter is freshly made, sugar & spices are suspended throughout the batter&#8230; but only for a fairly short period of time. Then gravity kicks in, and the sugar & spices sink to the bottom. At that point, you then need to whisk up the sugar & spices that have settled to the bottom after the batter has been preparedβ the most labor intensive part of this drink is that the batter pretty much needs to be whisked for almost every, single drink if you want to do it right. There is so much fluffy volume in a fresh batch of batter, and unless you&#8217;ve got an extra-long handle on your whisk to be able to dive down to the bottom of the container with, or are storing the batter in a fairly large, widespread container where the depth of the overall batter is shallow (which is not practical in service), it&#8217;s a messy pain to have to cut through the depth of the batter with a standard-length whisk handle in order to whip everything back together again. But once this batter deflates, it makes everything so much easier to whisk back up again in order to reincorporate the sugar & spices. Another enlightening point is that even in its deflated state, the batter comes back to life even after 24 hours and fluffs up again once you prepare an individual drink. Think about fluffy pancakes from dense pancake batter, so much easier to dole out onto the griddle because there&#8217;s no aeration in the batter&#8211; it&#8217;s pretty much the exact same premise here. I look forward to the collapse of the batter, because I can see the batter in its actual state, and it makes preparation a breeze. Also, much like a soup or stew, the flavor of the batter is much enhanced after collapsing and resting for a few hours. Another great benefit of allowing the batter to rest is that the sugar loses that distinctive raw &#8220;edge&#8221;. It melds with the other ingredients and all flavors have a chance to come together.</p>
<h3 id="heading-regarding-storage">Regarding storage:</h3>
<p>I currently utilize mason jars for storage . They are practical in service, they are easily sanitized, they store well in the fridge without taking up much room. I use a 2 oz (4 tablespoons) ladle to dole it out.</p>
<p>Here is how quickly you can make a batch of batter: again- it&#8217;s not about stability, it&#8217;s more about logistics. Your food processor is your best friend here.</p>
<ul>
<li>2 minutes: Assemble mise en place: food processor, sterile mason jar, spatula, sugar, spices, rum, angostura, vanilla, eggs</li>
<li>2 minutes: Crack & separate eggs into 2 separate bowls. Whites need a larger bowl.</li>
<li>2.5 minutes: Run egg whites in food processor, then transfer back to bowl. No need to rinse food processor bowl, just leave any residual egg white meringue thatβs clinging to the bowl there (because why waste it?).</li>
<li>1 minute: Dump egg yolks into food processor without any other ingredients and run approximately 1 minute until yolks become thin, runny and pale lemon in color.</li>
<li>2.5 minutes: Continue to run eggs yolks and incorporate sugar, spices, rum, vanilla and angostura. Alternate between adding 1/2 cup of sugar at a time, and some of the liquid ingredients.</li>
<li>Whisk yolk mixture back into egg whites, Xfer to mason jar. Date & refrigerate.</li>
</ul>
<p>The classic base spirits are rum & brandy. I use cognac in addition to rum, and this combination works well as they are both rich flavors which become harmonic in unison, and meld beautifully together with the batter. I did a lot of tinkering to the recipe- the original does not utilize angostura bitters, vanilla or nutmeg (within the batter, as opposed to acting as garnish). I added the vanilla for a comforting note, in addition to the other spices I added nutmeg into the batter for extra depth & warmth, reduced the sugar in original recipe from 5 lbs down to 2 lbs. </p>
<p>The original recipe also calls for boiling water instead of milk, but the milk provides a more pleasurable and cohesive mouthfeel to the overall structure of the drink. But my favorite addition to the batter overall was utilizing angostura bitters&#8211; I have always thought of Angostura as the ultimate Christmas bitters and flavor-wise that profile totally delivers. But beyond that, I found that Angostura also dries down the milk on the palate and helps to cut through the fat. It also provides needed structure so the addition makes a big difference- I feel the batter is downright cloying without it.</p>
<h3 id="heading-batch-yield">Batch yield:</h3>
<p>This recipe has been scaled down for home use, and will produce a 32 oz batch when freshly made, but will eventually deflate by 8 oz or so. So approx. 24 oz altogether (which translates into 12 drinks utilizing 2 oz of batter each). That said, this recipe is easily scaled up or down if necessary.</p>
<h3 id="heading-about-maintaining-a-sanitary-environment">About maintaining a sanitary environment:</h3>
<p>Where I&#8217;m a real stickler is in the handling of the eggs, and maintaining strict sanitary conditions on all levels. I only use organic eggs, and I think that helps to greatly reduce any potential of contamination prior to arrival. Once the eggs arrive in-house, they are dated, and they don&#8217;t leave the refrigerator until they are just about to be processed. Once the batter is processed, it immediately goes back into the fridge. Batter is never left out, even during service. Once a whisk or a ladle is used, it goes straight into boiling water until it can get sanitized in the dishwasher. I also have designated sheet pans to process T&J&#8217;s on, which also go into the dishwasher at any given moment. Safety label- As we are utilizing raw eggs here, it is of the utmost importance to note that eggs should always be refrigerated, the batter should be refrigerated immediately after preparation, and never be allowed to sit out at room temp. Proper refrigerator storage should be no higher than 41 degrees. All cylinders & surface counters utilized for any raw egg batter should be sanitized immediately after use.&#8221;</p>
<p>More safety: Please note that the consumption of raw eggs can be hazardous.</p>
<p>&#8211;Audrey Saunders</p>
/posts/feuerzangenbowleNate Eagle<iframe src="https://www.youtube.com/embed/FY3hTGmJJYo" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen class="iphone-vertical"></iframe>
<p>Original source: <a href="https://www.thedailybeast.com/fire-starter-ignite-your-night-with-flaming-punch">David Wondrich</a></p>
<p>This is a video from the first time I tried making a feuerzangenbowle.</p>
<p>The song playing is Manheim Steamroller&#8217;s <a href="https://www.youtube.com/watch?v=pNKc_6ZqxrY">God Rest Ye Merry, Gentlemen</a>, which has become our official song for lighting a feuerzangenbowle.</p>
<h1 id="heading-directions">Directions</h1>
<p>Get a large (3 or 4-quart) pot (I used a dutch oven) and heat the wine, citrus slices, lemon peel studded with the gloves, nutmeg, and the cinnamon to a simmer over low heat. The principle here, I think, is to give the wine a chance to be infused by the various ingredients you&#8217;ve put inside it. I&#8217;ve definitely cut corners a number of times, here, and heated the wine more quickly, so you can <em>do</em> it, but you&#8217;re compromising how much of the spice you get in the finished product.</p>
<p>Once you&#8217;ve mulled the wine as much as you want and it&#8217;s nice and hot, place it wherever you&#8217;re going to do the presentation. David Wondrich recommends putting it on a trivet over a sheet pan full of water for safety reasons, and I definitely did that the first couple times I tried it, but I&#8217;ve since relaxed.</p>
<p>I&#8217;ve accidentally gotten blue alcohol flame on the tablecloth making a different drink, and the tablecloth was fine. I&#8217;m not saying that you should be reckless, but I also don&#8217;t fully understand the level of caution some people feel about drinks on fire. (Famous last words.) Yes, be cautious about anything nearby that&#8217;s flammable. Take the precautions you feel are warranted. I do keep a pitcher full of water close at hand.</p>
<p>Lay a pair of fireplace tongs over the top. You don&#8217;t have fireplace tongs? Yeah, I didn&#8217;t either. I used two long, metal ladles crossed at an angle. Later, I bought a pair of specially made <a href="https://www.amazon.com/kela-Sugar-Tongs-5-5-Silver/dp/B000JICKRO">feuerzangenbowle tongs</a>. You need something metal (neither flammable nor meltable) that doesn&#8217;t have any channels that will send liquid, molten sugar anywhere it&#8217;s not supposed to be. You just need some contrivance to suspend the zuckerhut above the pot in a way that keeps it secure while allowing it to slowly drip through.</p>
<p>Soak the zuckerhut thoroughly with the rum, keeping a bunch of it in reserve to keep adding as the zuckerhut burns.</p>
<p>You&#8217;ll need a ladle that won&#8217;t melt or burn to ignite the rum and to spoon the remaining rum periodically over the zuckerhut. If it&#8217;s metal, be conscious that heat can potentially travel up the length of the handle, making it too hot to hold. I use a metal ladle and haven&#8217;t had a problem, but be careful. Take a ladleful of rum and light it. (As with most drinks you set fire to, the most practical and safe way to light them is with one of those long candle lighters.)</p>
<p>Take the lit ladleful of rum and pour it on to the soaked zuckerhut. The zuckerhut, then the whole surface of the wine below, should catch fire.</p>
<p>Now you&#8217;re running the show: add more rum from time to time as the flames seem to be dying down. At some point, you can add ladles full of wine and pour that on top, too. Be warned: the flames from the wine are yellow and beautiful, but less hot than the rum flames and won&#8217;t melt the sugar as quickly. I think there&#8217;s a real balance to play with in terms of how frequently you add rum/wine to the zuckerhut: letting it burn more without interference will result in sugar dripping into the wine that&#8217;s more caramalized, which affects the taste. Is it ideal to go for as caramelized a taste as possible? I don&#8217;t know. I haven&#8217;t been able to find anyone who even mentions this aspect of the drink. I asked Wondrich about it in person, and he seemed interested by the question, but hadn&#8217;t previously thought about it.</p>
<h1 id="heading-notes">Notes</h1>
<p>For the rum, Smith & Cross is delicious, sufficiently high proof to burn well, inexpensive, and readily available. I tried splitting it with Batavia Arrack, which was an option listed in Wondrich&#8217;s original recipe, and I found negligible taste alteration and a decrease in flammibility.</p>
<p>I&#8217;ve tried a few different red wines &#8211; Trader Joe&#8217;s Grower&#8217;s Reserve merlot seems to do the job just fine. I haven&#8217;t really noticed much difference when I&#8217;ve tried slightly more expensive wines.</p>
<p>I decreased the called-for lemons from two to one, as the original recipe was a little tart for my taste.</p>
<p>To stud the lemon peel with the cloves, I recommend using a metal cocktail pick to poke six holes in the peel along its length, then insert the cloves. If you try to poke the cloves directly through, a lot of them will break.</p>
<p>Zuckerhuts are the hardest thing to procure in this recipe: we&#8217;ve got <a href="https://www.germangourmet.com/about-us/">a German store</a> near us that&#8217;s an absolute delight to shop at before Christmas, but you can also order them online, though you&#8217;ll pay a fair shipping & handling fee. The logical solution at that point is: buy a whole bunch of them.</p>
/posts/mongolian-cauliflowerNate Eagle<h2 id="heading-directions">Directions</h2>
<p>Separate and cut the cauliflower into 1-inch florets. Peel the cauliflower stem and cut into thin slices. Set aside. Trim the scallions and chop them, including the entire green part. Set aside. Measure out the spices and place them, as well as the water, right next to the stove.</p>
<p>Heat the oil in a wok or a sautΓ© pan over high heat. When the oil is hot, add the mustard, cumin, and fennel. When the seeds stop sputtering, add the turmeric and immediately add the cauliflower.</p>
<p>Stir fry the cauliflower until itβs evenly coated with spice-infused oil. Add the scallions and water; mix and cover with a lid. Cook over medium heat and toss a couple times until the cauliflower is soft, about 10 minutes.</p>
<p>Uncover, fold in the coriander, and continue stir-frying until excess moisture evaporates and the cauliflower looks glazed, about 5 minutes.</p>
<h2 id="heading-notes">Notes</h2>
<p>I made this a number of times when I was younger: it&#8217;s my favorite way to have cauliflower. It comes from a paleo recipes site that I can no longer find.</p>
/posts/mincemeatNate Eagle<h2 id="heading-notes">Notes</h2>
<p>I got this recipe from a former coworker named Jeannelle. It&#8217;s pretty different from the jarred mincemeat I grew up with. But I found that it&#8217;s quite delicious in its own way: the nuts and apricots give it a different texture and flavor. I think it&#8217;s a little more popular with folks who aren&#8217;t already into mincemeat than the standard recipe.</p>
<p>The most important note I&#8217;d give is to really chop up the dried apricots finely: having big chunks of them in the mincemeat isn&#8217;t ideal.</p>
<p>I can never find sultanas: I just use golden raisins instead.</p>
<p>I also have a probably irrational aversion to calling it &#8220;mince&#8221; instead of &#8220;mincemeat,&#8221; but I don&#8217;t think I really have any ground to stand on.</p>