all repos — websitemeow @ 7685833af780d9105bbc4bb6d9e188b51358eabf

src/content/projects/_purrkit.mdx (view raw)

 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
---
name: "purrkit"
description: "a meowing, fully-typesafe set of packages for interacting with the discord API"

languages: ["TypeScript"]
stub: false

links:
  [
    { type: "source", label: "source", href: "https://tangled.org/vt3e.cat/purrkit.git" },
    { type: "website", label: "npm", href: "https://www.npmjs.com/package/@purrkit" },
  ]

messages:
  {
    author: "april",
    avatarUrl: "/avatar.webp",
    timestamp: "02:15",
    content:
      [
        "im writing my own and its gonna be so clean and im gonna do type magic god i love doing type magic",
        "its gonanbe called KITTEN and its gonna MEOW",
      ],
  }
---

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.

        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.

        this is the point where i decided i would Write my Own framework . to quote
        verbatim what i said:

        <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..

    </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.

    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.

    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
        },
    });
    ```

    ### 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`.

    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.

  </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.

    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.

    purrkit however treats your components like commands, you provide an optiosn schema,
    and purrkit handles the encode/decode for you.

    ```ts
    const ticketBtn = kitten.button("close-ticket", {
        options: { ticketId: option.string() },
        async run(interaction, { ticketId }) {
            await interaction.reply(`closing ticket ${ticketId}...`);
        },
    });

    const button = ticketBtn.button({ ticketId: "6767" }).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, for example.

    i would like to implement custom \{de,\}serialisers for custom IDs in 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"`.

    purrkit also conveniently checks the lengths of the resulting custom IDs, if
    they're larger than 100 chars, it\'ll throw a `CustomIdTooLong` error.

    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`.

  </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.

        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.

        ```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 };
        });

        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}`);
            },
        });
        ```

        ### how it work

        `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.

        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.

        but what if we need to stop! you can `throw new HaltExecution()`. kitten
        catches it, optionally fires a reply, and aborts the chain.
    </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

    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

    so, `@purrkit/rest`. discriminated unions, my beloved:

```ts
import { RESTClient } from "@purrkit/rest";

const client = RESTClient({ token: process.env.DISCORD_TOKEN });

const user = await client.users.me.get();

// 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
}
```

    **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

    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>
</section>