Lessons Learned as a Bun.sh and TypeScript Newbie

Published: 

Introduction

My friend RynoTheBearded runs an Internet radio station, The #OO 24/7 Creative Commons Radio! at ryno.cc . He and his listeners chat live in his IRC chat room #oo on the Zeronode IRC network at irc.zeronode.net .

In 2013, as RynoTheBearded was thinking about starting a weekly Top Ten live show, I decided to make an IRC bot for him to collect votes in the channel all week and during the Top Ten show. I built the system with PHP, MySQL, WordPress, and PHP-based IRC client framework that was actually already in an abandoned state when I imported it into my project. This first version of Pintsize has been online on my TornadoVPS VM ever since that time, largely unchaged. You can see code for the old app on SourceHut .

This summer, I grew tired of supporting the old not-upgraded WordPress, old version of PHP, and janky crashy IRC bot. I started a new project to rebuild Pintsize in TypeScript [SourceHut], and I used this an an opportunity to really dig in and understand the JavaScript/TypeScript ecosystem.

This article is a list of lessons I've learned during my rebuild project, presented in no particular order.

In the next few months I will finish the project and replace the old production instance of Pintsize .

Visual Studio Code

Visual Studio Code is an excellent open source and free-to-use IDE, and there are mutual integrations between VS Code and the tools and frameworks and libraries I use in this project.

I used the following extensions:

Bun.sh

I've found that the JavaScript/TypeScript ecosystem has a gentler learning curve if you start with Bun.sh instead of the usual Node.js ecosystem. Bun.sh has these things built in, without having to install and configure additional tools:

Make sure you use the bun init command to start a project.

To add a libary to your project from the npm.js packagae catalog: bun add PACKAGENAME

async/await

If you are doing server-side programming in JavaScript or TypeScript, you are going to be writing asynchronous code. The TypeScript support in your IDE helps you keep your types and function call signatures and all that straight, but one area where it is weak is when you accidentally call an asynchronous function synchronously.

Consider the following code that calls an async function:

const track = new Track()

// async load(id: number): Promise<boolean> { … }
track.load(id)

console.log(track.name)

It doesn't work as you might expect it would. Here is the corrected version:

const track = new Track()

// async load(id: number): Promise<boolean> { … }
await track.load(id) // ** Added 'await' keyword to wait for result of the Promise

console.log(track.name)

The reason it's common for IDEs to not catch and complain about this coding error is that the "incorrect" version is in fact valid code. track.load() does return something, immediately: it returns a Promise. Some advanced programming techniques may involve taking this Promise and putting it somewhere else, to be dealt with by another function. But if you meant to just use the result of the Promise, and the side-effects of the completion of the async function, you must use the await keyword. And you must remember to use it every time you call an async function.

(You can only omit await when you call an async function at the end of another function, and you don't use its return value.)

The only way to learn not to make this mistake is to make the mistake repeatedly and fix your problems as you work.

ActiveRecord / ORM

Prisma looks good, and I might use it on my next project.

But for Pintsize I chose to keep things "simple", and I wanted to avoid importing a lot of code I didn't really need.

First, I used PHPMyAdmin to design my database schema:

Database diagram

Then I imported the MySQL2 library and began building simple data classes that eventually ended up implementing the ActiveRecord pattern.

Since my ActiveRecord code was bespoke for my project, I was free to hack around on the actual ActiveRecord superclass, adding special features I need, without having to worry about someone else's design.

For example, my load_by_name() method and the check_name() function it depends on implement a heuristic for translating the livestream's Stream Title value reliably into a single existing or new pair of Artist and Track:

  1. For Artist, use MariaDB's default string collation to see if there's an Artist (extends MusicName) with this name (case-insensitive) and creates it if it doesn't exist.
  2. check_name() updates the record's capitalization if capitalization has changed. … But this is only ever done 10 times per record, to prevent forever changing how a name is displayed in IRC and on the web, when the broadcaster can't decide on a capitalization for a name.
  3. The same is done for Track (track title).

For a good demonstration of how I use my ActiveRecord pattern, see migrate_pintsize1_plays.ts and migrate_pintsize1_vote.ts .

If you have the time to dive deep into learning a platform, implementing the Active Record pattern in that platform is an excellent way to learn the patterns and pitfalls of creating classes and code modules and understanding how to inherit and re-use code. If you have no idea how to design an implementation of the Active Record pattern, study someone else's implementation in a different platform, and then try to do that here where you're learning.

Express

I used the Express microframework for building the web server part of Pintsize. It's simple and stays out of your way. There's a bit of a learning curve to understand how routing works.

Express encourages you to organize your route handler functions as bare functions. Coming from a lot of experience in the PHP world with frameworks like Laravel and Zend, I wanted my route handlers to be methods inside Controller classes.

You can make Express instantiate a Controller class for every request and then use a method in that class to handle the request. This allows you to put common setup code in the constructor, and inherit behavior from a Controller superclass.

I did this by creating a static handler() method in the Controller superclass that handles the instantiation.

/**
 * Generate a handler for an Express JS route
 * @param method The method or the name of the method to be called
 */
static handler<T extends typeof Controller>(
    this: T,
    method: string | Function
): RequestHandler {
    if (!(method instanceof Function)) {
        method = (this as any).prototype[method] as Function
    }
    return (req: Request, res: Response, next: NextFunction) =>
        method.bind(new (this as any)(req, res, next))()
}

If you don't know about Generics, learn about them. You'll go insane if you try to implement something like ActiveRecord without Generics.

This is how I call it from my routes.ts setup file:

// create route served by `commands()` method on a new Settings object
app.get('/settings/commands', settings.handler('commands'))

htmx

motivation

  • Why should only <a> & <form> be able to make HTTP requests?
  • Why should only click & submit events trigger them?
  • Why should only GET & POST methods be available?
  • Why should you only be able to replace the entire screen?

By removing these constraints, htmx completes HTML as a hypertext

--- The htmx home page explains itself

In short, if you're not building really complicated client-side UX, htmx handles all the client-side imperative scripting for you and lets you declaratively configure how forms work, while you can easily keep all of your business logic on the server.

The web UI for Pintsize is largely simple web forms built with HTML, CSS, and HTTP. Every user action on the client sends a request to the server, which renders a whole new screen as an HTML string. This is easy to program.

There is one place, so far, where htmx came in handy: on the admin screen for editing the list of IRC channels that the IRC bot lives in:

Channels list UI screenshot

When the full Channels screen renderes, my handler+view code sends a form with a table containing a rows of form elements:

active[0], channel[0], msg_type[0], wait_sec[0], throttle_count[0], throttle_sec[0]
active[1], channel[1], msg_type[1], wait_sec[1], throttle_count[1], throttle_sec[1]
active[2], channel[2], msg_type[2], wait_sec[2], throttle_count[2], throttle_sec[2]
…

This set of HTML form fields is easy to parse on the server side, mutate, and serialize back to HTML to send to the client.

I used htmx on the Del button:

<button hx-post={`/settings/channels/del/${num}`} hx-target="#chans">Del</button>

When you click Del, instead of submitting the whole screen --- and going to the route handler that writes to the database --- we submit the enclosing form to /settings/channels/del/ROW_NUM.

With htmx, you don't need a special separate View State model. The HTML string and the live HTML and form elements in the browser IS the view state.

/settings/channels/del parses the submitted form data, except for the selected row number, and returns it back to the client as HTML/htmx to be replaced into the #chans element (which is a tbody inside the form).

JSX/TSX Syntax without React

const greet_html = (hello_world_var:string): string => (
    <div>
        {hello_world_var}
    </div>
)

Building strings is so much better with JSX/TSX syntax, isn't it? I wanted to use this syntax in my TypeScript in this project, and I found that there's a library to help you do it without having to import and adopt a monster library like React or its cousins: Typed HTML

Install Typed HTML in your project with bun add typed-html, then edit your tsconfig.json and add:

// inside "compilerOptions":
"jsx": "react",
"jsxFactory": "elements.createElement",

Don't forget to configure your editor's tab sizes to make things work nice in main TypeScript logic files and in TSX files containing mostly HTML string building functions and HTML string templates.

To see how I build HTML and htmx responses in Pintsize let's walk through the Search / Search Results route handler:

  1. /search maps to and calls SearchController:index() .
  2. index() gets the s URL query paramter if any, and executes a search (which quickly returns no results if the query string is empty or too short).
  3. The search results and the search query s are passed into the view function in view/search/index.view.tsx .
  4. An HTML string for the main body of the page is assembled using TSX syntax and additionally calling other TSX functions in the same view/search/index.view.tsx module.
  5. SearchController:index() receives the body HTML string and passes it to Controller.layout(), which calls the default layout function (as no replacement layout has been set). The default is the main full-page HTML layout function in view/layout/layout.view.tsx .

JSX/TSX doesn't exist in .js and .ts files. Your files must have .jsx and .tsx extensions for the extended syntax to work. I made this mistake several times.

My particular combination of [ VS Code; the Bun.sh extension for VS Code; and typed-html ] has a bug which may or may not appear for everyone:

Whenever I create a new .tsx file, all of the functions (HTML elements) declared by the typed-html package don't work until after I touch my tsconfig.json file. In other words, if I go over to tsconfig.json, add and remove a space and save it, then my new .tsx file's buffer no longer shows the symbol name failures it showed a moment ago.

.editorconfig

The EditorConfig file format is an easy-to-read way to specify how to configure indentation and whitespace settings for groups of files in your project. I use the EditorConfig for VS Code extension.

Here is my .editorconfig from Pintsize:

root = true

[*]
indent_style = space
indent_size = 4

[**.md]
indent_size = 2
[**.html]
indent_size = 2
[**.json]
indent_size = 2
[src/view/**/*.view.*]
indent_size = 2

Strongly Typed Events

Strongly Typed Events makes it easy to use the publish/subscribe pattern with Events in your TypeScript project. Here is how Pintsize uses it:

My Stream Title Poller module defines a dispatcher:

const title_change_dispatcher = new SimpleEventDispatcher<StreamTitle>()
…
export const on_title_change = title_change_dispatcher.asEvent()

When it's time to broadcast to the whole process that a new Stream Title value has arrived…

function set_title(value: string) {
    const new_title = new StreamTitle(value)
    if (new_title.equals(stream_title)) {
        return
    }
    stream_title = new_title
    title_change_dispatcher.dispatchAsync(stream_title)
}

On the receiving end, in the IrcBot class's constructor:

// from module header:
import * as stream_poller from './stream_poller'
…

// from constructor:
stream_poller.on_title_change.subscribe(
    this.stream_title_change.bind(this)
)

… and that's all you need to have loosely coupled modules broadcasting messages to each other within a TypeScript process.

Comments

Add Comment

* Required information
5000
Powered by Commentics

Comments

No comments yet. Be the first!