Published: |
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 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:
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
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 aPromise
. Some advanced programming techniques may involve taking thisPromise
and putting it somewhere else, to be dealt with by another function. But if you meant to just use the result of thePromise
, and the side-effects of the completion of the async function, you must use theawait
keyword. And you must remember to use it every time you call anasync
function.(You can only omit
await
when you call anasync
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.
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:
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
:
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.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.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.
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 theController
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'))
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:
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).
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:
/search
maps to and calls SearchController:index()
.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).s
are passed into the view function in view/search/index.view.tsx
.body
of the page is assembled using TSX syntax and additionally calling other TSX functions in the same view/search/index.view.tsx
module.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 thetyped-html
package don't work until after I touch mytsconfig.json
file. In other words, if I go over totsconfig.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.
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 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.
This policy contains information about your privacy. By posting, you are declaring that you understand this policy:
This policy is subject to change at any time and without notice.
Reader-contributed comments on Glump.net are owned by their original authors, who reserve all rights.
Comments rules:
Comments