push
vi did:web:vt3e.cat
Thu, 13 Aug 2026 07:28:32 +0100
4 files changed,
377 insertions(+),
161 deletions(-)
M
src/components/DiscordMessages.astro
→
src/components/DiscordMessages.astro
@@ -30,7 +30,7 @@ flex-direction: row;
gap: 0.5rem; padding: 0.5rem; - background-color: hsl(var(--surface0)); + background-color: hsl(var(--crust)); border-radius: 1.5rem; .gutter {
M
src/content/projects/_purrkit.mdx
→
src/content/projects/_purrkit.mdx
@@ -27,215 +27,429 @@
import DiscordMessages from "../../components/DiscordMessages.astro"; <section> - <header> - <h2>what's a purrkit</h2> - <p>purrkit fixes this™</p> - </header> - <div class="content"> - i write a lot of discord stuff, and every time i started to write a new bot i'd end up - writing the exact same helpers, command loaders and interaction handlers ooover and ooooover - again. + <header> + <h2>what's a purrkit</h2> + <p>idk but it fixes this™</p> + </header> + <div class="content"> + i write a lot of discord stuff, and every time i started to write a new bot i'd end up + writing the exact same helpers, command loaders and interaction handlers ooover and ooooover + again. - it got very tiring to do this, so i started looking around for some discord.js frameworks, - the main two i found were [sapphire](https://sapphirejs.dev/) and [commandkit](https://commandkit.dev/). + it got very tiring to do this, so i started looking around for some discord.js + frameworks, the main two i found were [sapphire](https://sapphirejs.dev/) and + [commandkit](https://commandkit.dev/). - i did start out writing sapphire, it had a fair amount of niceities, but - there's SO. MUCH. BOILERPLATE. why am i making an entire class just to - listen for new messages . hoe come commands have a nice pretty central - registry but for buttons, modals, and etc. you're on your own? did they - just give up? also maybe i was stupid but it seemed to just freaking - swallow errors. + i did start out writing sapphire, it had a fair amount of niceities, but + there's SO. MUCH. BOILERPLATE. why am i making an entire class just to + listen for new messages . how come commands have a nice pretty central + registry but for buttons, modals, and etc. you're on your own? did they + just give up? also maybe im stupid (i am to be fair) but it seemed to just + freaking swallow errors. - this is the point where i decided i would Write my Own framework . to quote - verbatim what i said: + this is the point where i decided i would Write my Own framework . to quote + verbatim what i said: - <DiscordMessages message={frontmatter.messages} /> + <DiscordMessages message={frontmatter.messages} /> - at that point however a friend directed me to commandkit, i had a quick - look and i did like it, what's really interesting is that they use JSX - for components, i really liked that approach. but you were still on your - own for handling options and such . and there was no type magic . im a big - fan of type magic.. + at that point however a friend directed me to commandkit, i had a quick + look and i did like it, what's really interesting is that they use JSX + for components, i really liked that approach. but you were still on your + own for handling options and such . and there was no type magic . im a big + fan of type magic.. + so, of course, i went on to build my own, plus a rest & gateway implementation: + * `@purrkit/router` a web framework-like router for interactions + * `@purrkit/rest` a fully typed REST client (plus `@purrkit/ratelimit`) + * `@purrkit/types` autogenerated types from discord's openapi spec + * `@purrkit/gateway` the gateway implementation </div> </section> <section> - <header> - <h2>the type magic of slash options</h2> - <p>"but i beloved non-null asserting Everything"</p> - </header> - <div class="content"> - if you've written discord.js slash commands before then you know the pain of - options. vanilla discord.js just gives you `interaction.options.getString("name")`, - `getInteger(...)`, `getUser(...)`, and so on. + <header> + <h2>the type magic of slash options</h2> + <p>"but i loooooooove non-null asserting Everything!!"</p> + </header> + <div class="content"> + if you've written discord.js slash commands before then you know the pain of + options. vanilla discord.js just gives you `interaction.options.getString("name")`, + `getInteger(...)`, `getUser(...)`, and so on. - the problem is this returns `(string | User | number | ...) | null`. doesn't - matter that you configured the option as required, typescript has NO idea, - discord.js ALSO has NO idea. so you're doing `?? ""` or slapping `!` on everything - which is just a bit nasty isn't it. THEN multiply that by 5-6 options per - command and it's, it's just pure misery. it's gross. + the problem is this returns `(string | User | number | ...) | null`. doesn't + matter if you configured the option as required, typescript has NO idea, + discord.js ALSO has NO idea. so you're doing `?? ""` or slapping `!` on everything + which is just a bit nasty. THEN multiply that by 5-6 options per command and + yea it's just gross. - purrkit fixes this™ by making you declare your options up front as a schema - instead of imperatively fishing for them: + purrkit fixes this™ by making you declare your options up front as a schema + instead of imperatively fishing for them: - ```ts - const whois = kitten.command("whois", { - description: "get info about a user", - options: { - user: option.user.required("the user"), - ephemeral: option.boolean.optional("hide response"), - }, - async run(interaction, { user, ephemeral }) { - // `user` is strictly typed as User - // `ephemeral` is strictly typed as boolean | undefined - }, - }); - ``` + ```ts + const whois = kitten.command("whois", { + description: "get info about a user", + options: { + user: option.user.required("the user"), + ephemeral: option.boolean.optional("hide response"), + }, + async run(interaction, { user, ephemeral }) { + // `user` is strictly typed as User + // `ephemeral` is strictly typed as boolean | undefined + }, + }); + ``` - ### how it work + ### how it work - i wrote a mapped type, `InferOptions<O>`, that chews through your options object. - checks if `required: true`, and if so rips out the inner type (`User`, `string`, - whatever). if it's not required it unions with `undefined`. + the type magic is a single mapped type, `InferOptions<O>` in `pkgs/router/src/options.ts`. + it looks through your options object: for each key, if the option is + `required: true` it extracts the inner type (`User`, `string`, whatever), + and if it's not required it unions that type with `undefined`. - then at runtime kitten intercepts the command, walks the same schema, calls the - right discord.js getter (`getUser`, etc), and just. hands you a plain object that - matches the type EXACTLY. and it just works and it's beautiful as you can see above. + ```ts + type InferOptions<O> = { + [K in keyof O]: O[K] extends Option<infer Type, infer Required> + ? Required extends true + ? Type + : Type | undefined + : never; + }; + ``` - </div> + then at runtime, on the other side, `parseSlashOptions` in + `pkgs/router/src/kitten.ts` keeps a `typeToMethod` map that knows each option + type's discord.js getter - `string` -> `getString`, `user` -> `getUser`, + `mentionable` -> `getMentionable`, `attachment` -> `getAttachment`, and so on. + it walks the exact schema you declared, calls the right getter, and just. + hands you a plain object that matches the type EXACTLY. and it just works and + it's beautiful as you can see above. how did i live without this for so long + </div> + </section> <section> - <header> - <h2>the stateless component problem</h2> - <p>"how should i store this button's data" the convenient uri-encoded custom id:</p> - </header> - <div class="content"> - discord components (so modals, select menus, modals and etc) are stateless - lil kittens. all discord gives you is a `customId` string with up to 100 chars, - which, to be fair, is probably how it should be. + <header> + <h2>the stateless component problem</h2> + <p>"how should i store this button's data" the convenient uri-encoded custom id:</p> + </header> + <div class="content"> + discord components (buttons, modals, select menus, etc) are stateless lil + kittens. all discord gives you is a `customId` string with up to 100 chars, + which, to be fair, is probably how it should be. - your options are pretty much: - * generate a unique id for each component and store the state in redis or whatever, - * or mash the state into a string like `ticket_close:ticketId`, untyped, and fragile, - and breaks if your data has an underscore in it. + your options are pretty much: + * generate a unique id for each component and store the state in redis or whatever, + * or mash the state into a string like `ticket_close:ticketId`, untyped, and fragile, + and breaks if your data has an unaccounted for separator character. + + purrkit treats components the same as it does commands, you provide an options schema, + and purrkit handles encode/decode for you. + + ```ts + const ticketButton = kitten.button("close-ticket", { + options: { ticketId: option.string() }, + async run(interaction, { ticketId }) { + await interaction.reply(`closing ticket ${ticketId}...`); + }, + }); + + const button = ticketButton.button({ ticketId: "67" }).setLabel("close"); + ``` + + now this won't necesarrily cut it for all use cases, but those use cases i + believe are fairly rare? i haven't encountered them, yet at least. and if + you do encounter them, you can implement your own serialiser in middleware, + though i would like to make this like a first-class feature in the future. + + ### how it work + + call `.button({ ticketId: "6767" })` and purrkit walks your schema keys, + `encodeURIComponent`s each value, joins with `:`. so you get `"close-ticket:6767"`. + and it returns a discord.js `ButtonBuilder`, `.id()` just builds the string, + instantiates the appropriate builder, and returns it to you. then of course, + same deal for select menus (`stringSelectMenu()`, `userSelectMenu()`, + `roleSelectMenu()`, ...) and modals (`modal(title)`). + + purrkit also conveniently checks the lengths of the resulting custom IDs, if + they're larger than 100 chars, it'll throw a `CustomIdTooLong` error. and when + you `register()` a component it'll warn you if the static overhead of the + name plus keys leaves less than 25 chars of budget for actual values. - purrkit however treats your components like commands, you provide an optiosn schema, - and purrkit handles the encode/decode for you. + when purrkit receives a component interaction, it splits by the colon, + finds & reads the relevant schema, decodes the values, and hands it to your `run`. + </div> - ```ts - const ticketBtn = kitten.button("close-ticket", { - options: { ticketId: option.string() }, - async run(interaction, { ticketId }) { - await interaction.reply(`closing ticket ${ticketId}...`); - }, - }); +</section> - const button = ticketBtn.button({ ticketId: "6767" }).setLabel("close"); - ``` +<section> + <header> + <h2>context! mutation! without `any`!</h2> + <p>believe it or not . purrkit fixes this™ too</p> + </header> + <div class="content"> + instead of middleware fetching something and throwing it into the `interaction` + object or whatever, like SOME web frameworks, purrkit accumulates it into a fully + typed `context` object. - now this won't necesarrily cut it for all use cases, but those use cases i - believe are fairly rare? i haven't encountered them, for example. + the context accumulation here is basically stolen from koa (`ctx` threading through + middleware) crossed with trpc's procedure builder (chaining `.use()`, narrowing types + with each call). i wanted that shape for purrkit's middleware. - i would like to implement custom \{de,\}serialisers for custom IDs in future. + ```ts + const authed = kitten.builder().use(async (interaction) => { + const dbUser = await db.users.find(interaction.user.id); + if (!dbUser) throw new HaltExecution({ content: "unauthorised" }); + return { dbUser }; + }); - ### how it work + authed.command("balance", { + description: "check your balance", + async run(interaction, args, ctx) { + // ctx.dbUser is fully typed and guaranteed to exist here + await interaction.reply(`balance: £${ctx.dbUser.balance}`); + }, + }); + ``` - call `.button({ ticketId: "6767" })` and purrkit walks your schema - keys, `encodeURIComponent`s each value, joins with `:`. so you get `"close-ticket:6767"`. + ### how it work - purrkit also conveniently checks the lengths of the resulting custom IDs, if - they're larger than 100 chars, it\'ll throw a `CustomIdTooLong` error. + `CommandBuilder` (in `pkgs/router/src/commands.ts`) is generic over its context, + `CommandBuilder<Ctx>`. every `.use(middleware)` call looks at what your middleware + returns (`NewCtx`) and hands you back a new builder typed as `CommandBuilder<Ctx & NewCtx>`. + intersection after intersection, it accumulates, down the whole chain, + like a snowball, or an onion, like an ogre. layers of types. - when the click comes back in, kitten splits on the colon, reads the schema, - casts everything back to proper types (`"true"` -> boolean, `"67"` -> number) - and then hands it to your `run`. + so by the time `.command()` gets called, `ctx` in your `run` is the + intersection of everything every middleware in that chain returned. with + No Globals Harmed in the Making. - </div> + what if we need to stop! you can `throw new HaltExecution()`. kitten + catches it, optionally sends a reply, and aborts the chain before `run`..... + runs. + + errors that *aren't* deliberate don't just get swallowed either. purrkit has: + a local `onError` on the command/interaction, then any builder-level handlers + from `.onError()`, then a global one on the `Kitten` instance. each handler + receives whatever context had been accumulated up to the point of failure, + so you know exactly what was known when things went wrong. and it all + surfaces through a typed `EventEmitter` -- `debug`, `info`, `warn`, + `error` -- instead of some annoying logging abstraction. + + then there's afterware, which runs in a `finally` block after everything: + clean success, thrown error, or deliberate halt, it runs. and it's typed + too - `.after<PaymentResult>()` means your `result` param is + `PaymentResult | undefined`, and typescript will refuse to let a command + built from that builder return anything else. + </div> + </section> <section> - <header> - <h2>mutating context without the `any`</h2> - <p>believe it or not . purrkit fixes this™ too</p> - </header> - <div class="content"> - instead of middleware fetching something and throwing it into the `interaction` - object or whatever, like SOME web frameworks, purrkit accumulates it into a fully - typed `context` object. + <header> + <h2>and the rest of the menu</h2> + <p>subcommands, context menus, autocomplete, all the little things</p> + </header> + <div class="content"> + the router isn't just slash commands, and stopping there would be a + disservice. the same builder that makes a command also makes everything + else, and it all shares the same middleware/context pipeline: + + * **subcommands and subcommand groups.** a `.subcommand("set", ...)` chains + right off a parent command, and `.group("admin", (group) => ...)` nests a + whole cluster of them. kitten figures out which group and which subcommand + the interaction landed on and routes accordingly + * **context menus.** `userContextMenu("...")` and `messageContextMenu(...)`, + with presets for where they're allowed (ie, guilds, dms, everywhere) + * **autocomplete.** declare `autocomplete(interaction, value)` right on an + option and kitten routes the autocomplete interaction and responds with + your choices for you + + ```ts + config.subcommand("set", { + description: "set a config value", + options: { + key: option.string("the option key", { + required: true, + autocomplete(interaction, value) { + const keys = ["prefix", "welcomeMessage", "modLogChannel"]; + return keys + .filter((key) => key.startsWith(value)) + .map((key) => ({ name: key, value: key })); + }, + }), + }, + async run(interaction, { key }) { + await interaction.reply(`setting updated: ${key}`); + }, + }); + ``` + + * **static choices and localisation.** `choices` on an option, and + `nameLocalizations`/`descriptionLocalizations` threaded all the way down - + options included. plus `nsfw`, `defaultMemberPermissions`, and integration + type/context flags on commands and groups. + * **`sync()`** turns the whole registry into a REST payload (schema to + `APIApplicationCommandOption`s, via `transformOptionsForDiscord`) and + pushes it to discord. you should pass `{ guildId }` while developing so + you're not waiting out global command propagation. + </div> + +</section> - the context accumulation here is basically stolen from koa (`ctx` threading through - middleware) crossed with trpc's procedure builder (chaining `.use()`, narrowing types - with each call). i wanted that shape for purrkit's middleware. +<section> + <header> + <h2>a rest client that doesn't throw</h2> + <p>@purrkit/rest</p> + </header> + <div class="content"> + outside the router i still needed to just. get things from the discord api + directly sometimes. and most fetch wrappers `throw` on 400/500 which is SO + annoying because an http error code is not always a code exception. - ```ts - const authed = kitten.builder().use(async (interaction) => { - const dbUser = await db.users.find(interaction.user.id); - if (!dbUser) throw new HaltExecution({ content: "unauthorized" }); - return { dbUser }; - }); + like, if i ask for a user and they deleted their account, a 404 is a valid + expected state. i don't want to wrap every call in a giant `try/catch` and + have my error variable collapse into `unknown` mush - authed.command("balance", { - description: "check your balance", - async run(interaction, args, ctx) { - // ctx.dbUser is fully typed and guaranteed to exist here - await interaction.reply(`balance: $${ctx.dbUser.balance}`); - }, - }); - ``` + the @discord.js/rest implementation specifically also just gives you + `unknown` types for payloads, you have to manually type cast. like why, + you have so many resources, why would you do this. - ### how it work + so, `@purrkit/rest`. discriminated unions, my beloved: - `CommandBuilder` is generic over its context, `CommandBuilder<Ctx>`. - every `.use(middleware)` call looks at what your middleware returns - (`NewCtx`) and hands you back a new builder typed as `CommandBuilder<Ctx & NewCtx>`. - intersection after intersection, it accumulates, down the whole chain, - like a snowball, or an onion, like an ogre. layers of types. + ```ts + import { RESTClient } from "@purrkit/rest"; - so by the time `.command()` gets called, `ctx` in your `run` is the - intersection of everything every middleware in that chain returned. with - No Globals harmed in the making. + const client = RESTClient({ token: process.env.DISCORD_TOKEN }); + const user = await client.users.me.get(); - but what if we need to stop! you can `throw new HaltExecution()`. kitten - catches it, optionally fires a reply, and aborts the chain. + // typescript forces you to check `.ok` before accessing `.data` + if (user.ok) { + console.log(user.data.username); // UserPIIResponse + } else if (user.status === 429) { + console.log(user.data.retry_after); // RatelimitedResponse + } else { + console.error(user.data.message); // ErrorResponse + } + ``` + + (this pattern is inspired by [@atcute/client](https://tangled.org/mary.my.id/atcute/tree/trunk/packages/clients/client) + by [mary.my.id](https://mary.my.id), so thank u mary :D) + + ### how it work + + every method resolves to either `{ ok: true, status: number, data: T }` or + `{ ok: false, status: number, data: E }`. control flow analysis means + `if (user.ok)` is a real type guard: inside it `data` is narrowed to the + success shape, in the `else` it's narrowed to the error shape - and checking + `user.status === 429` narrows it to `RatelimitedResponse` specifically. + + and the path is a lie. `client.users.me.get()` looks like chained object + properties but it's all one big `Proxy`. property access appends a path + segment, the terminal method name maps to an http verb (`get` -> GET, + `create`/`post` -> POST, `update`/`patch` -> PATCH, `delete` -> DELETE), so + the call chain *is* the url. path parameters work the same way: + `client.guilds("1359315933797285948").channels.get()` is typed all the way + down, the id slot showing up as a function in the type tree. and you can even + `await client.users.me` directly - the proxy has a `then` trap that resolves + the GET for you. + + all the response types come straight from `@purrkit/types`, autogenerated off + discord's own openapi spec, so it should never be out of sync. + + did i mention basically all of this is just type magic? there's very little + runtime code here. </div> </section> <section> - <header> - <h2>a rest client that doesn't throw</h2> - <p class="subtitle">@purrkit/rest</p> - </header> - <div class="content"> - outside the router i still needed to just. talk to the discord api directly sometimes. and most fetch wrappers `throw` on 400/500 which is SO annoying because an http error code is not always a code exception + <header> + <h2>types generaeted from the spec</h2> + <p>@purrkit/types</p> + </header> + <div class="content"> + all of that rest typing relies on `@purrkit/types`, which is autogenerated. + straight from [discord's openapi spec](https://github.com/discord/discord-api-spec). + + there's a generator in `pkgs/types/src/generator/generator.ts` that goes + through the spec's schemas and paths and generates two files: `models.ts`, + with every object discord has ever defined, and `routes.ts`, with a + `DiscordApiEndpoints` interface. + + ```ts + // models.ts, autogenerated + export type Snowflake = string; + + export interface UserResponse { + id: Snowflake; + username: string; + global_name: string | null; + ... + } + ``` + </div> + +</section> + +<section> + <header> + <h2>rate limit handling for @purrkit/rest</h2> + <p>@purrkit/ratelimit</p> + </header> + <div class="content"> + @purrkit/rest does not handle rate limits by default, you must instead + write your own implementation or use this package. + + ```ts + import { RESTClient } from "@purrkit/rest"; + import { RateLimitManager } from "@purrkit/ratelimit"; + + const limiter = new RateLimitManager(); + const rest = RESTClient({ token, fetch: limiter.fetch }); + ``` + </div> + +</section> - like, if i ask for a user and they deleted their account, a 404 is a completely valid expected state, not a catastrophe. i don't want to wrap every call in a giant `try/catch` and have my error variable collapse into `unknown` mush +<section> + <header> + <h2>gateway</h2> + <p>@purrkit/gateway</p> + </header> + <div class="content"> + and finally gateway. it's a gateway implementation. - so, `@purrkit/rest`. discriminated unions, my beloved: + i did not enjoy writing this, dealing with compression was Not fun. -```ts -import { RESTClient } from "@purrkit/rest"; + ```ts + import { Gateway } from "@purrkit/gateway"; -const client = RESTClient({ token: process.env.DISCORD_TOKEN }); + const gateway = Gateway({ token, intents }); + gateway.on("MESSAGE_CREATE", (message) => { + console.log(`new message: ${message.content}`); + }); + gateway.connect(); + ``` + </div> -const user = await client.users.me.get(); +</section> -// typescript forces you to check `.ok` before accessing `.data` -if (user.ok) { - console.log(user.data.username); // UserPIIResponse -} else if (user.status === 429) { - console.log(user.data.retry_after); // RatelimitedResponse -} else { - console.error(user.data.message); // ErrorResponse -} -``` +<section> +<header> + <h2>ambitions</h2> +</header> +<div class="content"> + <p> + i have big ambitions for purrkit, i want to replace discord.js entirely in my + bots, i'm currently working on a fullstack purrkit, it's not exactly *difficult*, but it's *very* + tedious and draining to work on. + </p> - **how it works:** every method resolves to either `{ ok: true, status: 200, data: T }` or `{ ok: false, status: number, data: E }`. control flow analysis means `if (user.ok)` is a real type guard, inside it `data` is narrowed to the success shape, in the `else` it's narrowed to the error shape. no casting, no praying, typescript just KNOWS + <p> + at Some Point(tm) in the future, it will however be done........ + </p> - all the response types come straight from `@purrkit/types`, autogenerated off discord's own OpenAPI spec, so it's never out of sync with reality (unless discord lies to us, which, fair, has happened) +</div> - </div> </section>
M
src/content/projects/bluebell/index.mdx
→
src/content/projects/bluebell/index.mdx
@@ -9,5 +9,4 @@ [
{ type: "website", label: "website", href: "https://next.bbell.vt3e.cat" }, { type: "source", label: "source", href: "https://tangled.org/bbell.vt3e.cat/bluebell" }, ] -testimonials: [{ text: "bluebell is a great client for bluesky on the web.", author: "John Doe" }] ---
M
src/pages/index.astro
→
src/pages/index.astro
@@ -4,10 +4,13 @@ import { Layout } from "@/layouts";
import { ProjectCard } from "@/components"; const links = [ - { label: "on bluesky", href: "https://bsky.app/profile/did:web:vt3e.cat" }, - { label: "on my git server", href: "https://git.vt3e.cat/" }, - { label: "on tangled (git)", href: "https://tangled.org/did:web:vt3e.cat" }, - { label: "on listenbrainz", href: "https://listenbrainz.org/user/aprvi" }, + { label: "bluesky", href: "https://bsky.app/profile/did:web:vt3e.cat" }, + { label: "my git server", href: "https://git.vt3e.cat/" }, + { + label: "tangled (git but social)", + href: "https://tangled.org/did:web:vt3e.cat", + }, + { label: "listenbrainz", href: "https://listenbrainz.org/user/aprvi" }, ]; const testimonials = [