GDPLE is my Wordle-inspired US state economy guessing game that I wrote last year, but this October I did some work on both the frontend and backend that I thought warranted a blog post.
After reading Domain Modeling Made Functional I decided to re-write the GDPLE backend in F#. The player is shown a breakdown of a US state's GDP by sector and has five attempts to guess the state correctly. Initially, the backend was written in TypeScript and Node using NestJS, which is based off of Express but includes some useful features for dependency injection and decorators to mark endpoints. While it strikes a good balance between something like Spring and vanilla Express, NestJS was overkill for the GDPLE backend, and you can end up writing lots of boilerplate when using the library.
The prior NestJS controller class is shown below, and the new F# backend has the same endpoints. The frontend hits POST /puzzle_session
to get a UUID for the player's attempt on that day's puzzle that is written to local storage. That UUID is included in POST /guess
to submit a guess and GET /answer/:id
if the player has exhausted their guesses before reaching the correct answer. The GET /economy
provides the GDP breakdown of the mystery US state for the frontend to create a treemap visualisation of the mystery state economy.
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get("/economy")
async getTargetStateEconomy(): Promise<StateEconomy> { ... }
@Post("/puzzle_session")
async postPuzzleSession(): Promise<IPuzzleSession> { ... }
@Post("/guess")
async ostGuess(@Body() body: GuessSubmissionRequest):
Promise<GuessSubmissionResponse> { ... }
@Get("/answer/:id")
async getPuzzleAnswer(@Param() params: PuzzleAnswerRequest):
Promise<PuzzleAnswerResponse> { ... }
}
F# is a joy to write in, but because I was pretty directly re-writing TypeScript into F#, there are a couple of TypeScript language features that I missed. The US state economy data was represented in a JSON file, and being able to directly import the file with import stateRecordList from "./UsStates"
was convenient. TypeScript union types made working with hierarchical economic data very succinct as shown by the getTotalGdp
function below.
export interface NonLeafEconomyNode {
gdpCategory: string;
children: Array<NonLeafEconomyNode | LeafEconomyNode>;
}
export interface LeafEconomyNode {
gdpCategory: string;
gdp: number;
}
getTotalGdp(economy: NonLeafEconomyNode | LeafEconomyNode): number {
if ("children" in economy) {
return economy.children
.map((node) => this.getTotalGdp(node))
.reduce((prev, cur) => prev + cur, 0);
} else {
return economy.gdp;
}
}
The function takes advantage of TypeScript's permissiveness and if ("children" in economy)
isn't the best way to distinguish between the two node types. It is possible to do something equivalent with more type safety in F# by replacing the direct JSON import with a JSON type provider class and using discriminated unions as shown below, but it ended up being unwieldy by introducing four separate types for the economy data. Because only the leaf nodes of the economy tree structure have GDP data, calculating total GDP means distinguishing between a leaf node that must return a GDP value and a non-leaf node where the GDP sum needs to be recursively called over all child nodes. I am sure there is a way to use option types more creatively and coerce the type provider into using them, but at first pass I wasn't able to make it work, and I wanted to get something up-and-running relatively quickly. Type providers are such a great F# language feature, so I hope I'll soon find the time to get these working properly.
let getTotalGdp (economyNode: StateEconomies.Root) =
let rec loop (node: Node) =
match mode with
| Leaf leaf -> leaf.Gdp
| StateEconomy se -> (se.Children) |> Array.map loop |> array.sum
| OuterChild oc -> oc.Children |> Array.map loop |> array.sum
| MiddleChild mc -> mc.Children |> Array.map loop |> array.sum
loop economyNode.StateEconomy |> Math.Round |> Convert.ToInt64
Another aspect of the backend that I want to return to pertains to the database. While I ended up using F# Dapper, I originally wanted to use either an SQL type provider or SqlHydra for better database related type checking while editing, but these required referencing an OS-specific .dll
and I didn't want the hassle while moving between MacOS and Linux while writing the new backend. When I tried to decrement the id
column of every row in the target_states
table (represented as puzzleAnswerTable
in Dapper), I faced a compiler error as shown below because I wasn't able to use the puzzleAnswer
field in such a self-referential way inside the update
statement. This meant I had to use the raw query instead, with even less type safety than what F# Dapper provides:
let deleteObsoletePuzzleAnswers (dbConnection: DbConnection) =
// More elegant, but wouldn't compile
let updatePuzzleAnswer (puzzleAnswer: PuzzleAnswer) =
{id=puzzleAnswer.id-obsoletePuzzleAnswerCount; name=puzzleAnswer.name; gdp=puzzleAnswer.gdp}
// error FS0039: The value or constructor 'puzzleAnswer' is not defined.
update {
for puzzleAnswer in puzzleAnswerTable do
set (updatePuzzleAnswer puzzleAnswer)
} |> ignore
// Cruder, but compiled
dbConnection.Execute(
$"UPDATE target_states SET id = id - {obsoletePuzzleAnswerCount}, updatedAt = CURRENT_TIMESTAMP"
)
|> ignore
While working on the F# re-write I found out that GitHub allows for self-hosted CI/CD runners, and I wish I had found out about these sooner. All the CI/CD I had done in the past had been on GitLab CI/CD runners or Azure DevOps pipelines and I mistakenly thought that GitHub's equivalent - GitHub Actions - was a paid offering. This means that in the past, deploying one of my personal projects onto my VPS (virtual private server) meant either using scp
or git pull
before manually restarting some systemd
services. Every once in a while I'd forget to restart NGINX, or I'd mess up file permissions by accidentally deploying as root
and while this was a great way to stay on top of my system administration skills, it could get annoying and introduced more friction while trying to push out updates. My GitHub actions for the backend were straightforward to implement but not very sophisticated: I'm running the actions directly on the VPS hosting the backend. But the CI/CD Actions made my life much easier while addressing the bugs on the F# backend. Instead of having to SSH onto the VPS for every fix, I could push out small fixes over lunch.
The CI story was more interesting for the frontend: I kept running out of memory while running vite build
, which wasn't terribly surprising given the 1 GB of memory on the VPS:
<--- Last few GCs --->
[1044134:0x67be140] 48423 ms: Scavenge (reduce) 380.7 (391.3) -> 380.0 (391.6) MB, 1.79 / 0.00 ms (average mu = 0.210, current mu = 0.079) allocation failure;
[1044134:0x67be140] 49129 ms: Mark-Compact (reduce) 381.1 (391.6) -> 379.2 (391.6) MB, 697.59 / 0.05 ms (average mu = 0.262, current mu = 0.313) allocation failure; scavenge might not succeed
<--- JS stacktrace --->
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
The JavaScript bundle was relatively small at 445.79 kB/145.92 kB when gzip compressed. Luckily I was using Preact as a drop-in-replacement for React, which gzips down to 9.96 kB but my problems seemed to be with bundling Material UI. There were over 10,000 'module level directive' warnings associated with the @mui
NPM package:
$ npx vite build &> /tmp/build
$ cat /tmp/build | grep -c 'node_modules/@mui.*Module level directives cause errors when bundled'
10924
There has to be some way to reduce Node's memory footprint or work around these Material UI issues, but I wasn't able to do so and never liked the component library that much anyway. When an actual professional frontend engineer recommended I look into React Aria I had an excuse to replace the library entirely. It's a very simple frontend and I only ended up using the Button
, Modal
, and Dialog
React Aria components. The most complicated part of the frontend is the autocomplete text input for submitting a US State name as a guess, and if I was using React then the ComboBox
element would have worked great for this input. But I ended up using an Autocomplete
element from the Mantine component library because of a possible bug in Preact.
I set up another repository with a side-by-side comparison between Preact and React to troubleshoot the bug. Both applications render only a ComboBox
with a single ListBoxItem
underneath it as a selectable option. When the Preact application is run, the following error appears in the developer console:
Uncaught TypeError: Cannot set property previousSibling of #<Node> which has only a getter
at $681cc3c98f569e39$export$b34a105447964f9f.appendChild (Document.ts:119:13)
at Object.insertBefore (portals.js:49:22)
The relevant section of Document.ts
is shown below, this is part of React Aria used to build a variety of different components.
export class BaseNode<T> {
//...
appendChild(child: ElementNode<T>) {
//...
if (this.lastChild) {
this.lastChild.nextSibling = child;
child.index = this.lastChild.index + 1;
child.previousSibling = this.lastChild;
} else {
child.previousSibling = null; // Line 119 of Document.ts: error thrown here
child.index = 0;
}
//...
}
//...
}
The React application in that repository has no issue with rendering ComboBox
. No Preact errors are thrown if ListItemBox
elements are left out, which of course defeats the purpose of having the ComboBox
in the first place. This means that the problem is with Preact and ListBoxItem
in particular. With respect to the error itself, in the code segment above the child.constructor.name
is equal to "HTMLUnkownElement"
for Preact. The rest of this section is more speculative - I know very little about Preact internals. However, in comparing two stack frames up from BaseNode#appendChild
, Preact is calling the method on a lower level of the virtual DOM hierarchy than React, and I don't think that this lower level exists.
The code segment below is taken two stack frames above BaseNode#appendChild
. For Preact, parentVNode.type
and parentVNode._dom
have values of "item"
and undefined
respectively, and the _dom
property of a Preact VNode
is 'The [first (for Fragments)] DOM child of a VNode'.1 While the function call looks a little different for React, child.constructor
and child.node.type
are [[FunctionLocation]] Document.ts:227
and "item"
. While React is inserting an "item"
virtual DOM element into the DOM, Preact looks to be inserting the first child of an "item"
virtual DOM element, which is undefined
.
Because the text inside of the ListBoxItem
is the lowest level in the component hierarchy, I assume is that the "item"
DOM element is the content between the ListBoxItem
tags, which is 'Aardvark' in my bug demonstration repository. Line 119 of Document.ts
is only reached once in both the Preact and React applications, so this isn't a matter of React not yet reaching the lowest level of the component tree, and the ListBoxItem
definition is const ListBoxItem = createLeafComponent('item', function (props, forwardedRef, item) {...})
which likely explains the "item"
in both function calls.
// preact: children.js:343
function insert(parentVNode, oldDom, parentDom) {
//...
parentDom.insertBefore(parentVNode._dom, oldDom || null);
//...
}
// react: react-dom-development.js:11069
function appendChildToContainer(container, child) {
//...
parentNode.insertBefore(child, container);
//...
}
There's a good chance that this isn't a true bug and is rather a tradeoff in Portal
components that the Preact maintainers had to make, but in the coming days I'll post an issue in the GitHub repository for the project. If it's not a bug then I'll be curious to see what I got wrong here.
I would have preferred to use React Aria for the autocomplete over importing a second component library, but my VPS can build and deploy the frontend with GitHub actions without running out of memory. To my surprise, this frontend rewrite barely impacted the JavaScript bundle size. In comparing the repository before the F# rewrite at ee865c8
with the current most recent commit fa98105
as of writing, the bundle size shrank by less than 5% down to 423 kB. When building on an Apple Silicon M1 processor, the peak memory during the build also went down a modest amount from 47.56 MB to 45.04 MB. However, the build time went from 9.62 to 3.42 seconds, and the number of modules transformed from 12,473 to 2,716.2
Instead of rewriting the frontend to build on one of the cheapest servers offered by Digital Ocean, I could have instead moved the action runners to a homelab server and done any number of things to deploy it onto the VPS - something I plan to do anyway. But what's the fun in that?
Values taken using the MacOS time
command, averaged over three measurements↩︎