gruber 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,1004 @@
1
+ # Gruber
2
+
3
+ An isomorphic JavaScript library for creating web apps.
4
+
5
+ > Named for [Hans](https://purl.r0b.io/gruber)
6
+
7
+ ## Foreword
8
+
9
+ I don't really know if I should be making a JavaScript library like this.
10
+ The various ideas it's composed of have been floating around in my mind for a year or so and writing this has helped explore those ideas.
11
+ The documentation below is written as if the library I want to make exists, **it does not**.
12
+
13
+ I quite like this documentation-driven-design.
14
+ It really helps to think through the concepts and ideas of something before spending lots of time building it.
15
+
16
+ If this is something that interests you, reach out to me on [Mastodon](https://hyem.tech/@rob).
17
+
18
+ ## Background
19
+
20
+ I've spent the past few years working on JavaScript backends and nothing has really stuck with me.
21
+ There have been lots of nice ideas along the way but no one solution ever felt like home.
22
+ It always felt like starting from scratch for each project.
23
+ Some of the apps I've made:
24
+
25
+ - [Open Lab Hub](https://github.com/digitalinteraction/hub.openlab.dev)
26
+ — Deno + Oak + vue3 + vite
27
+ - [BeanCounter](https://github.com/digitalinteraction/beancounter)
28
+ — Node.js + Koa + vanilla js + parcel
29
+ - [MozFest Plaza](https://github.com/digitalinteraction/mozfest)
30
+ — Node.js + Koa + vue2 + webpack
31
+ - [Sticker Stories](https://github.com/digitalinteraction/sticker-stories)
32
+ — Node.js + Koa + vue3 + vite
33
+ - [Data Diaries](https://github.com/digitalinteraction/data-diaries)
34
+ — Node.js + Koa + vue3 + vite
35
+ - [DataOfficer](https://github.com/digitalinteraction/data-officer)
36
+ — Deno + Acorn
37
+ - [IrisMsg](https://github.com/digitalinteraction/iris-msg/tree/master)
38
+ — Node.js + Express + native app
39
+ - [Poster Vote](https://github.com/digitalinteraction/poster-vote)
40
+ — Node.js + Express + vue + webpack
41
+
42
+ ## About
43
+
44
+ Gruber is a library of composable utilities for creating isomorphic JavaScript applications,
45
+ that means web-standards JavaScript on the front- and backend.
46
+ It's bet is that web-standards aren't going to change, so it is be based around them to create apps that don't break in the future.
47
+ There's also a hope that [WinterCG](https://wintercg.org/work) works some stuff out.
48
+
49
+ Gruber acknowledges that web-standards don't do everything we want (yet) and that they aren't implemented properly everwhere.
50
+ For this reason, the core of Gruber is agnostic but there are helpers for using common runtimes & libraries with the core.
51
+
52
+ Gruber itself is a library and can be used however you like. There are **patterns** which you can apply if you like.
53
+ Patterns are ways of structuring your code if you don't already have opinions on the matter.
54
+ They also help to explain why Gruber is made in the way it is.
55
+
56
+ With a common agnostic core, there can be modules built on top that can be used agnostically too.
57
+ If the modules themselves are agnostic of course.
58
+
59
+ There is a lot not in Gruber too. By design things like CORs should be implemented at a higher level.
60
+ A Gruber app should be run behind a reverse proxy and that can do those things for you.
61
+
62
+ ## Focus
63
+
64
+ - `URLPattern` based routing that is testable
65
+ - `fetch` based routes using [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request)
66
+ and [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response)
67
+ - Simple database migrations
68
+ - Configuration to control how apps work
69
+ - A common core for reusable modules to built be upon
70
+
71
+ ## Design goals
72
+
73
+ - Composability — logic should be composed together rather than messily intertwined
74
+ - Standards based — where available existing standards should be applied or migrated towards
75
+ - Agnostic — a frontend framework or backend runtime shouldn't be forced upon you
76
+ - Patterns — how you _could_ use modules rather than enforce an implementation
77
+ - Minimal — start small, carefully add features and consider removing them
78
+ - No magic — it's confusing when you don't know whats going on
79
+
80
+ ## HTTP server
81
+
82
+ First a HTTP route to do something:
83
+
84
+ **hello-route.js**
85
+
86
+ ```js
87
+ import { defineRoute, HttpError } from "gruber";
88
+
89
+ // A route is a first-class thing, it can easily be passed around and used
90
+ export default defineRoute({
91
+ method: "GET",
92
+ pathname: "/hello/:name",
93
+ handler({ request, url, params }) {
94
+ if (params.name === "McClane") {
95
+ throw HttpError.unauthorized();
96
+ }
97
+ return new Response(`Hello, ${params.name}!`);
98
+ },
99
+ });
100
+ ```
101
+
102
+ A route is a definition to handle a specific HTTP request by returning a response.
103
+ It defines which method and path it is responding to and an asynchronous function to handle the request.
104
+
105
+ The request is a fetch [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request)
106
+ and the [response too](https://developer.mozilla.org/en-US/docs/Web/API/Response).
107
+
108
+ It also takes the `url` (as a [URL](https://developer.mozilla.org/en-US/docs/Web/API/URL)) of the request and `params`.
109
+ The parameters are matched from the pathname, part of the result of [URLPattern.exec](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern/exec).
110
+ In this example `name` is matched in the request URL and is used to process the request.
111
+
112
+ Let's add the route to a Node.js server:
113
+
114
+ **server.js**
115
+
116
+ ```js
117
+ import { createServer } from "node:http";
118
+ import { NodeRouter } from "gruber";
119
+
120
+ import helloRoute from "./hello-route.js";
121
+
122
+ export const routes = [helloRoute];
123
+
124
+ export async function runServer(options) {
125
+ const router = new NodeRouter({ routes });
126
+ const server = createServer(router.forHttpServer());
127
+
128
+ await new Promise((resolve) => server.listen(options.port, resolve));
129
+ console.log("Listening on http://localhost:%d", options.port);
130
+ }
131
+ ```
132
+
133
+ Then you could have a cli:
134
+
135
+ **cli.js**
136
+
137
+ ```ts
138
+ import yargs from "yargs";
139
+ import { hideBin } from "yargs/helpers";
140
+
141
+ import { runServer } from "./server.js";
142
+
143
+ const cli = yargs(hideBin(process.argv))
144
+ .help()
145
+ .demandCommand(1, "a command is required");
146
+
147
+ cli.command(
148
+ "serve",
149
+ "run the http server",
150
+ (yargs) => yargs.option("port", { type: "number", default: 3000 }),
151
+ (args) => runServer(args),
152
+ );
153
+
154
+ try {
155
+ await cli.parseAsync();
156
+ } catch (error) {
157
+ console.error("Fatal error:", e);
158
+ }
159
+ ```
160
+
161
+ If you were using Deno, you can alter your **server.js**:
162
+
163
+ > You'd have to change the `const cli = yargs(hideBin(process.argv))` of **cli.js** too
164
+
165
+ ```js
166
+ import { DenoRouter } from "@gruber/deno/mod.js";
167
+
168
+ import helloRoute from "./hello-route.js";
169
+
170
+ export const routes = [helloRoute];
171
+
172
+ export async function runServer(options) {
173
+ const router = new DenoRouter({ routes });
174
+
175
+ Deno.serve({ port: options.port }, router.forDenoServe());
176
+
177
+ console.log("Listening on http://localhost:%d", options.port);
178
+ }
179
+ ```
180
+
181
+ That's how the same HTTP logic can be run on Deno and Node.
182
+ Gruber doesn't expect you'll change runtime during a project,
183
+ but now you can have more-common-looking code on different projects.
184
+
185
+ ## Configuration
186
+
187
+ In production, it is very useful to be able to configure how an app behaves without having to modify the code and redeploy the entire app.
188
+ That is what configuration is for, it lets you change how the app runs by altering the configuration.
189
+ The configuration can come from different places too, like a JSON file, environment variables or maybe arguments to your CLI.
190
+
191
+ [12 fractured apps](https://medium.com/@kelseyhightower/12-fractured-apps-1080c73d481c) really inspired the design of configuration, to summerise it should be:
192
+
193
+ - Load in from the environment and/or configuration files
194
+ - Have sensible defaults so it does not fail if environment variables or configuration files are missing
195
+ - Apply a precidence of configuration between different sources
196
+ - Always structurally valid so the rest of you code can assume that
197
+
198
+ Things you might want to configure:
199
+
200
+ - How much logging to do
201
+ - The databases to connect to
202
+ - Which features to turn on or off
203
+ - Tokens for thirdy-party APIs
204
+ - Who to send emails from
205
+
206
+ Gruber provides the utilities to specify this information and load it in from the environment youe code is running in.
207
+ It uses a pattern of `environment variables > configuration file > fallback` to decide which values to use.
208
+ The end result is a configuration object you can share between all of your code that you know is well-formed.
209
+
210
+ Configuration is heavily inspired by [superstruct](https://docs.superstructjs.org/) which has a lovely API.
211
+
212
+ Building on the [HTTP server](#http-server) above, we'll setup configuration. Still using Node.
213
+
214
+ **config.js**
215
+
216
+ ```js
217
+ import superstruct from "superstruct";
218
+ import { getNodeConfiguration } from "gruber";
219
+
220
+ const pkg = JSON.parse(fs.readFileSync("./package.json", "utf8"));
221
+ const config = getNodeConfiguration({ superstruct });
222
+
223
+ export function getSpecification() {
224
+ return config.object({
225
+ env: config.string({
226
+ variable: "NODE_ENV",
227
+ fallback: "development",
228
+ }),
229
+
230
+ selfUrl: config.url({
231
+ variable: "SELF_URL",
232
+ fallback: "http://localhost:3000",
233
+ }),
234
+
235
+ // Short hands?
236
+ meta: config.object({
237
+ name: config.string({ flag: "--app-name", fallback: pkg.name }),
238
+ version: config.string({ fallback: pkg.version }),
239
+ }),
240
+
241
+ database: config.object({
242
+ url: config.url({
243
+ variable: "DATABASE_URL",
244
+ flag: "--database-url",
245
+ fallback: "postgres://user:secret@localhost:5432/database",
246
+ }),
247
+ }),
248
+ });
249
+ }
250
+
251
+ // Load the configuration and parse it
252
+ export function loadConfiguration(path) {
253
+ return config.load(path, getSpecification());
254
+ }
255
+
256
+ // TypeScript thought:
257
+ // export type Configuration = Infer<ReturnType<typeof getSpecification>>
258
+
259
+ // Expose the configutation for use in the application
260
+ export const appConfig = await loadConfiguration(
261
+ new URL("./config.json", import.meta.url),
262
+ );
263
+
264
+ // Export a method to generate usage documentation
265
+ export function getConfigurationUsage() {
266
+ return config.getUsage(getSpecification());
267
+ }
268
+ ```
269
+
270
+ ### Usage info
271
+
272
+ The usage output will be:
273
+
274
+ ```
275
+ Usage:
276
+
277
+ | key | type | argument | variable | default value |
278
+ | ============ | ====== | ============== | ============ | ============= |
279
+ | env | string | ~ | NODE_ENV | "development" |
280
+ | selfUrl | url | ~ | SELF_URL | "http://localhost:3000" |
281
+ | meta.name | string | --app-name | ~ | gruber-app |
282
+ | meta.version | string | ~ | ~ | 1.2.3 |
283
+ | database.url | url | --database-url | DATABASE_URL | postgres://user:top_secret@database.io:5432/database_name |
284
+
285
+ Defaults:
286
+ {
287
+ "env": "development",
288
+ "selfUrl": "http://localhost:3000",
289
+ "meta": {
290
+ "name": "gruber-app",
291
+ "version": "1.2.3"
292
+ },
293
+ "database": {
294
+ "url": "postgres://user:top_secret@database.io:5432/database_name"
295
+ }
296
+ }
297
+ ```
298
+
299
+ ### Fallbacks
300
+
301
+ You can provide a configuration file like **config.json** to load through the config specification:
302
+
303
+ ```jsonc
304
+ {
305
+ "env": "production",
306
+ "selfUrl": "http://localhost:3000",
307
+ "meta": {
308
+ "name": "gruber-app",
309
+ "version": "1.2.3",
310
+ },
311
+ "database": {
312
+ "url": "postgres://user:secret@localhost:5432/database",
313
+ },
314
+ }
315
+ ```
316
+
317
+ When loaded in, it would:
318
+
319
+ - override `env` to be "production"
320
+ - override `safeUrl` and parse it as a `URL` object
321
+ - override `meta.version` but use the default `meta.name`
322
+ - override `database.url` to be the production value
323
+
324
+ If run with a `NODE_ENV=staging` environment variable, it would set `env` to "staging"
325
+
326
+ ### Considerations
327
+
328
+ You should to consider the security for your default values,
329
+ e.g. if you app runs differently under NODE_ENV=production
330
+ and you forget to set it, what is the implication?
331
+
332
+ If you use something like `dotenv`, ensure it has already loaded before creating the `Configuration`
333
+
334
+ You could add extra checks to `loadConfiguration` to ensure things are correct in production,
335
+ this can be done like so:
336
+
337
+ ```js
338
+ export function loadConfiguration() {
339
+ const appConfig = config.loadJsonSync(path, getSpecification());
340
+
341
+ // Only run these checks when running in production
342
+ if (appConfig.env === "production") {
343
+ if (appConfig.database.url.includes("top_secret")) {
344
+ throw new Error("database.url has not been configured");
345
+ }
346
+ // more checks ...
347
+ }
348
+
349
+ return appConfig;
350
+ }
351
+ ```
352
+
353
+ This checks the default value for `database.url` is not used when in production mode.
354
+
355
+ ### Configuration commands
356
+
357
+ We can add a CLI command to demonstrate using this configuration.
358
+ Add this command to **cli.js**, below the "serve" command":
359
+
360
+ ```ts
361
+ import { appConfig, getConfigurationUsage } from "./config.js";
362
+
363
+ // cli.command(
364
+ // "serve",
365
+ // ...
366
+ // );
367
+
368
+ cli.command(
369
+ "config",
370
+ "outputs computed configuration",
371
+ (yargs) => yargs,
372
+ (args) => {
373
+ console.log(appConfig);
374
+ },
375
+ );
376
+
377
+ cli.command(
378
+ "usage",
379
+ "outputs computed configuration",
380
+ (yargs) => yargs,
381
+ (args) => {
382
+ console.log(getConfigurationUsage());
383
+ },
384
+ );
385
+ ```
386
+
387
+ ## Migrations
388
+
389
+ Building on [Configuration](#configuration), we'll add database migrations to our Gruber app.
390
+
391
+ First, lets create a migration, **migrations/001-add-people.js**:
392
+
393
+ ```js
394
+ import { defineMigration } from "gruber";
395
+
396
+ export default defineMigration({
397
+ async up(sql) {
398
+ await sql`
399
+ CREATE TABLE "people" (
400
+ "id" SERIAL PRIMARY KEY,
401
+ "created" TIMESTAMP NOT NULL DEFAULT NOW(),
402
+ "name" VARCHAR(255) NOT NULL,
403
+ "avatar" VARCHAR(255) DEFAULT NULL
404
+ )
405
+ `;
406
+ },
407
+ async down(sql) {
408
+ await sql`
409
+ DROP TABLE "people"
410
+ `;
411
+ },
412
+ });
413
+ ```
414
+
415
+ and we need to set up our database with **database.js**
416
+
417
+ ```js
418
+ import process from "node:process";
419
+ import postgres from "postgres";
420
+ import { loader, getNodePostgresMigrator } from "gruber";
421
+ import { appConfig } from "./config.js";
422
+
423
+ export const useDatabase = loader(async () => {
424
+ // You could do some retries/backoffs here
425
+ return postgres(appConfig.database.url);
426
+ });
427
+
428
+ export async function getMigrator() {
429
+ return getNodePostgresMigrator({
430
+ directory: new URL("./migrations/", import.meta.url),
431
+ sql: await useDatabase(),
432
+ });
433
+ }
434
+ ```
435
+
436
+ > `loader` is a utility to run a function once and cache the result for subsequent calls.
437
+ > It returns a method that either calls the factory function or returns the cached result.
438
+
439
+ ### Migrate command
440
+
441
+ Then we can add to our CLI again, **cli.js**:
442
+
443
+ ```ts
444
+ import { getMigrator } from "./database.js";
445
+
446
+ // cli.command(
447
+ // "config",
448
+ // ...
449
+ // );
450
+
451
+ cli.command(
452
+ "migrate up",
453
+ "migrates the database to match code",
454
+ (yargs) => yargs,
455
+ async (args) => {
456
+ const migrator = await getMigrator();
457
+ await migrator.up();
458
+ },
459
+ );
460
+
461
+ cli.command(
462
+ "migrate down",
463
+ "nukes the database",
464
+ (yargs) => yargs,
465
+ async (args) => {
466
+ const migrator = await getMigrator();
467
+ await migrator.down();
468
+ },
469
+ );
470
+ ```
471
+
472
+ With that in place, you can run the migrations.
473
+ Gruber internally will set up the migration infrastructure too.
474
+
475
+ The `Migrator` is agnostic and provides a bespoke integration with [postgres.js](https://github.com/porsager/postgres).
476
+ When used agnostically, it facilitates the preperation and running of migrations.
477
+ With postgres, it uses that facilitation to add a `migrations` table to track which have been run and execute new ones.
478
+
479
+ ## Testing
480
+
481
+ Let's write a test for our route.
482
+
483
+ **hello-route.test.js**
484
+
485
+ ```js
486
+ import assert from "node:assert";
487
+ import { describe, it } from "node:test";
488
+
489
+ import { NodeRouter } from "gruber";
490
+ import helloRoute from "./hello-route.js";
491
+
492
+ describe("hello route", () => {
493
+ const router = new NodeRouter({ routes: [helloRoute] });
494
+
495
+ it("uses GET", () => {
496
+ assert.equal(helloRoute.method, "GET");
497
+ });
498
+ it("says hello", async () => {
499
+ const response = await router.getResponse(new Request("/hello/Geoff"));
500
+ assert.equal(response.status, 200);
501
+ assert.equal(await response.text(), "Hello, Geoff!");
502
+ });
503
+ it("blocks McClane", async () => {
504
+ const response = await router.getResponse(new Request("/hello/McClane"));
505
+ assert.equal(response.status, 401);
506
+ });
507
+ });
508
+ ```
509
+
510
+ You use the same `Request` & `Response` objects to test your code!
511
+ No need for mock servers.
512
+
513
+ Next testing routes when there is a dependency (e.g. a database)
514
+
515
+ **search-route.js**
516
+
517
+ ```js
518
+ import { defineRoute } from "gruber";
519
+ import { useDatabase } from "./database.js";
520
+
521
+ export const searchRoute = defineRoute({
522
+ method: "POST",
523
+ pathname: "/search",
524
+ async handler({ request }) {
525
+ const body = await request.json();
526
+ const sql = await useDatabase();
527
+
528
+ const result = await sql`
529
+ SELECT id, created, name, avatar
530
+ FROM people
531
+ WHERE LOWER(name) LIKE LOWER(${"%" + body.name + "%"})
532
+ `;
533
+ return Response.json(result);
534
+ },
535
+ });
536
+ ```
537
+
538
+ and to test the route, **search-route.test.js**
539
+
540
+ ```js
541
+ import assert from "node:assert";
542
+ import { describe, it, beforeEach } from "node:test";
543
+ import { NodeServer, magicLoad } from "gruber";
544
+
545
+ import searchRoute from "./search-route.js";
546
+ import { useDatabase } from "./database.js";
547
+
548
+ // WIP — exploring "magic loader" snippet below
549
+ // Thoughts — this is very much in the magic realm, I don't like it
550
+
551
+ describe("search route", () => {
552
+ const router = new NodeRouter({ routes: [searchRoute] });
553
+ beforeEach(() => {
554
+ useDatabase[magicLoad] = () => [
555
+ {
556
+ id: 1,
557
+ created: new Date("2024-01-01"),
558
+ name: "Geoff Testington",
559
+ avatar: null,
560
+ },
561
+ ];
562
+ });
563
+
564
+ it("uses POST", () => {
565
+ assert.equal(searchRoute.method, "POST");
566
+ });
567
+ it("returns people", async () => {
568
+ const request = new Request("/search", {
569
+ method: "POST",
570
+ headers: {
571
+ "content-type": "application/json",
572
+ },
573
+ body: JSON.stringify({ name: "Geoff" }),
574
+ });
575
+
576
+ const response = await router.getResponse(request);
577
+ assert.equal(response.status, 200);
578
+ assert.deepEqual(await response.json(), [
579
+ {
580
+ id: 1,
581
+ created: new Date("2024-01-01"),
582
+ name: "Geoff Testington",
583
+ avatar: null,
584
+ },
585
+ ]);
586
+ });
587
+ });
588
+ ```
589
+
590
+ More complicated functions should be broken down into different parts.
591
+ Parts which themselves can be tested individually.
592
+
593
+ Let's try again, **search-route.js**:
594
+
595
+ ```js
596
+ import { defineRoute } from "gruber";
597
+ import { useDatabase } from "./database.js";
598
+
599
+ export function queryPeople(sql, body) {
600
+ return sql`
601
+ SELECT id, created, name, avatar
602
+ FROM people
603
+ WHERE LOWER(name) LIKE LOWER(${"%" + body.name + "%"})
604
+ `;
605
+ }
606
+
607
+ export const searchRoute = defineRoute({
608
+ method: "POST",
609
+ pathname: "/search",
610
+ async handler({ request }) {
611
+ const body = await request.json();
612
+ const sql = await useDatabase();
613
+ return Response.json(await queryPeople(sql, body));
614
+ },
615
+ });
616
+ ```
617
+
618
+ Then you could test `queryPeople` on its own, so add to **search-route.test.js**:
619
+
620
+ ```js
621
+ import searchRoute, { queryPeople } from "./search-route.js";
622
+
623
+ // describe('search route', ...)
624
+
625
+ // TODO: this is still a bit gross
626
+
627
+ describe("queryPeople", () => {
628
+ it("formats for LIKE", async () => {
629
+ let args = null;
630
+ const result = await queryPeople((...a) => (args = a), {
631
+ name: "Geoff",
632
+ });
633
+ assert.equals(args[1], ["%Geoff%"]);
634
+ });
635
+ });
636
+ ```
637
+
638
+ TODO: I'm not happy with this, will need to come back to it.
639
+
640
+ ## Meta APIs
641
+
642
+ There are APIs within Gruber for using it at a meta level.
643
+ That means internal interfaces for using Gruber in different ways than described above.
644
+
645
+ ### Configuration API
646
+
647
+ The Configuration class is the base for how configuration works and can be used by itself to make you configuration work in different ways.
648
+
649
+ To see how it works, look at the [Node](./node/source/configuration.js) and [Deno](./deno/configuration.ts) implementations.
650
+
651
+ You can use the static `getOptions` method both subclasses provide and override the parts you want.
652
+ These are the options:
653
+
654
+ - `superstruct` — Configuration is based on [superstruct](https://docs.superstructjs.org/), you can pass your own instance if you like.
655
+ - `readTextFile(url)` — How to load a text file from the file system
656
+ - `getEnvironmentVariable(key)` — Return a matching environment "variable" for a key
657
+ - `getCommandArgument(key)` — Get the corresponding "flag" from a CLI argument
658
+ - `stringify(value)` — How to write the whole configuration back to a string
659
+ - `parse(string)` — Convert a plain string into a raw config object
660
+
661
+ For example, to override in Node:
662
+
663
+ ```js
664
+ import { Configuration, getNodeConfigOptions } from "gruber";
665
+ import Yaml from "yaml";
666
+ import superstruct from "superstruct";
667
+
668
+ const config = new Configuration({
669
+ ...getNodeConfigOptions({ superstruct }),
670
+ getEnvionmentVariable: () => undefined,
671
+ stringify: (v) => Yaml.stringify(v),
672
+ parse: (v) => Yaml.parse(v),
673
+ readTextFile: (url) => fetch(url).then((r) => r.text()),
674
+ });
675
+ ```
676
+
677
+ This example:
678
+
679
+ - Disables loading environment variables
680
+ - Uses YAML instead of JSON encoding
681
+ - Fetches text files over HTTP (just because)
682
+
683
+ ### Migrator API
684
+
685
+ The migrator is similarly abstracted to [Configuration](#configuration-api).
686
+ Where the postgres migrator is an subclass of `Migrator`.
687
+ This class has the base methods to run migrations up or down and knows which migrations to run.
688
+
689
+ ```js
690
+ import fs from "node:fs/promises";
691
+ import { defineMigration } from "gruber";
692
+
693
+ async function getRecords() {
694
+ try {
695
+ return JSON.parse(await fs.readFile("./migrations.json"));
696
+ } catch {
697
+ // This _should_ only catch not-found errors
698
+ return {};
699
+ }
700
+ }
701
+
702
+ async function writeRecords(records) {
703
+ await fs.writeFile("./migrations.json", JSON.stringify(records));
704
+ }
705
+
706
+ async function getDefinitions() {
707
+ return [
708
+ defineMigration({
709
+ up: (fs) => fs.writeFile("hello.txt", "Hello, World!"),
710
+ down: (fs) => fs.unlink("hello.txt"),
711
+ }),
712
+ defineMigration({
713
+ up: (fs) => fs.writeFile("version.json", '{ "version": "0.1" }'),
714
+ down: (fs) => fs.unlink("version.json"),
715
+ }),
716
+ ];
717
+ }
718
+
719
+ async function execute(definition, direction) {
720
+ console.log("migrate %s", direction, definition.name);
721
+
722
+ const records = await getRecords();
723
+
724
+ if (direction === "up") {
725
+ await definition.up(fs);
726
+ records[name] = true;
727
+ }
728
+ if (direction === "down") {
729
+ await definition.down(fs);
730
+ delete records[name];
731
+ }
732
+
733
+ await writeRecords(records);
734
+ }
735
+ export function getMigrator() {
736
+ return new Migrator({ getDefinitions, getRecords, execute });
737
+ }
738
+ ```
739
+
740
+ This is an example migrator that does things with the filesystem.
741
+ It has a store of records at `migrations.json` to keep track of which have been run.
742
+ When it runs the migrations it'll update the json file to reflect that.
743
+
744
+ With the code above in place, you can use the migrator to run and undo migrations with the `up` and `down` methods on it.
745
+
746
+ ## Core library
747
+
748
+ ### defineRoute
749
+
750
+ `defineRoute` is the way of creating route primatives to be passed to your router to handle web traffic.
751
+
752
+ ```js
753
+ import { defineRoute } from "gruber";
754
+
755
+ export const helloRoute = defineRoute({
756
+ method: "GET",
757
+ pathname: "/hello/:name",
758
+ handler({ request, url, params }) {
759
+ if (params.name === "McClane") {
760
+ throw HTTPError.unauthorized();
761
+ }
762
+ return new Response(`Hello, ${params.name}!`);
763
+ },
764
+ });
765
+ ```
766
+
767
+ ### HTTPError
768
+
769
+ `HTTPError` is an Error subclass with specific information about HTTP errors.
770
+ Gruber catches these errors and converts them into HTTP Responses.
771
+
772
+ ```js
773
+ import { HTTPError } from "gruber";
774
+
775
+ throw HTTPError.badRequest();
776
+ throw HTTPError.unauthorized();
777
+ throw HTTPError.notFound();
778
+ throw HTTPError.internalServerError();
779
+ throw HTTPError.notImplemented();
780
+ ```
781
+
782
+ The static methods are implemented on an "as-needed" basis,
783
+ more can be added in the future as the need arrises.
784
+ They directly map to HTTP error as codes documented on [MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status).
785
+
786
+ ```js
787
+ const teapot = new HTTPError(418, "I'm a teapot");
788
+ ```
789
+
790
+ You can also instantiate your own instance with whatever status code and text you like.
791
+ With an instance, you can ask it to create a Response for you.
792
+
793
+ ```js
794
+ teapot.toResponse();
795
+ ```
796
+
797
+ Currently, you can't set the body of the generated Response objects.
798
+ This would be nice to have in the future, but the API should be thoughtfully designed first.
799
+
800
+ ### Postgres
801
+
802
+ #### getPostgresMigratorOptions
803
+
804
+ `getPostgresMigratorOptions` generates the default options for a `PostgresMigrator`.
805
+ You can use it and override parts of it to customise how the postgres migrator works.
806
+
807
+ ## Node.js library
808
+
809
+ There are some specific helpers to help use Gruber in Node.js apps.
810
+
811
+ ### KoaRouter
812
+
813
+ `KoaRouter` lets you use Gruber routes in an existing Koa application, for example:
814
+
815
+ ```js
816
+ import Koa from "koa";
817
+ import helmet from "koa-helmet";
818
+ import cors from "@koa/cors";
819
+ import static from "koa-static";
820
+ import mount from "koa-mount";
821
+
822
+ import { KoaRouter } from "gruber/koa-router.js";
823
+
824
+ const router = new KoaRouter({ routes: "..." });
825
+ const app = new Koa()
826
+ .use(helmet())
827
+ .use(cors({ origin: "https://example.com" }))
828
+ .use(mount("/public", koaStatic("public")))
829
+ .use(router.middleware());
830
+
831
+ app.listen(3000);
832
+ ```
833
+
834
+ ### ExpressRouter
835
+
836
+ `ExpressRouter` lets you use Gruber routes in an Express application, for example:
837
+
838
+ ```js
839
+ import express from "express";
840
+ import cors from "cors";
841
+ import helmet from "helmet";
842
+ import morgan from "morgan";
843
+
844
+ import { ExpressRouter } from "gruber/express-router.js";
845
+
846
+ const router = new ExpressRouter({ routes: "..." });
847
+ const app = express()
848
+ .use(helmet())
849
+ .use(cors())
850
+ .use(morgan("tiny"))
851
+ .use(router.middleware());
852
+
853
+ app.listen(3000);
854
+ ```
855
+
856
+ ### Polyfil
857
+
858
+ For older version of Node.js that don't support the latest web-standards,
859
+ there is a polyfil import you can use to add support for them to your runtime.
860
+
861
+ ```js
862
+ import "gruber/polyfil";
863
+ ```
864
+
865
+ This currently polyfils these APIs:
866
+
867
+ - [URLPattern](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern)
868
+ using [urlpattern-polyfill](https://www.npmjs.com/package/urlpattern-polyfill)
869
+
870
+ ### HTTP helpers
871
+
872
+ There are a bunch of methods to help deal with Node's `http` library, like converting to `Request` and `Response objects`
873
+
874
+ #### getFetchRequest
875
+
876
+ `getFetchRequest` converts a node [http.IncomingMessage](https://nodejs.org/api/http.html#class-httpincomingmessage) into a [Fetch API Request](https://developer.mozilla.org/en-US/docs/Web/API/Request).
877
+
878
+ ```js
879
+ import http from "node:http";
880
+ import { getFetchRequest } from "gruber/node-router.js";
881
+
882
+ const server = http.createServer(async (req, res) => {
883
+ const request = getFetchRequest(req);
884
+ res.send(await req.text());
885
+ });
886
+ ```
887
+
888
+ #### getFetchHeaders
889
+
890
+ `getFetchHeaders` converts a `http.IncomingHttpHeaders` into a [Fetch API Headers](https://developer.mozilla.org/en-US/docs/Web/API/Headers) object.
891
+
892
+ ```js
893
+ import { getFetchHeaders } from "gruber/node-router.js";
894
+
895
+ const headers = getFetchHeaders({
896
+ accept: "text/html",
897
+ "set-cookie": ["bourbon=yummy", "digestive=nice"],
898
+ "content-type": "application/json",
899
+ });
900
+ ```
901
+
902
+ #### getIncomingMessageBody
903
+
904
+ `getIncomingMessageBody` gets the body of a [http.IncomingMessage](https://nodejs.org/api/http.html#class-httpincomingmessage) as a [Steams API ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream).
905
+
906
+ ```js
907
+ import http from "node:http";
908
+ import { getIncomingMessageBody } from "gruber/node-router.js";
909
+
910
+ const server = http.createServer((req) => {
911
+ const stream = getIncomingMessageBody(req);
912
+ // ...
913
+ });
914
+ ```
915
+
916
+ ---
917
+
918
+ <!-- -->
919
+ <!-- -->
920
+ <!-- -->
921
+ <!-- -->
922
+ <!-- -->
923
+
924
+ ## nice snippets
925
+
926
+ **simpler loader**
927
+
928
+ ```ts
929
+ interface Loader<T> {
930
+ (): T;
931
+ }
932
+
933
+ export function loader<T>(handler: Loader<T>): Loader<T> {
934
+ let result: T | null = null;
935
+ return () => {
936
+ if (!result) result = handler();
937
+ return result;
938
+ };
939
+ }
940
+ ```
941
+
942
+ **magic loader**
943
+
944
+ ```ts
945
+ interface Loader<T> {
946
+ (): T;
947
+ }
948
+
949
+ // I know I said no magic...
950
+ export const magicLoad = Symbol("magicLoad");
951
+
952
+ export function loader<T>(handler: Loader<T>): Loader<T> {
953
+ let result: T | null = null;
954
+ return () => {
955
+ if (loader[magicLoad]) return loader[magicLoad];
956
+ if (!result) result = handler();
957
+ return result;
958
+ };
959
+ }
960
+ ```
961
+
962
+ **generic backoff method**
963
+
964
+ ```js
965
+ async function retryWithBackoff({
966
+ maxRetries = 20,
967
+ interval = 1_000,
968
+ handler,
969
+ }) {
970
+ for (let i = 0; i < maxRetries; i++) {
971
+ try {
972
+ const result = await handler();
973
+ return result;
974
+ } catch {
975
+ await new Promise((r) => setTimeout(r, i * interval));
976
+ }
977
+ }
978
+ console.error("Could not connect to database");
979
+ process.exit(1);
980
+ }
981
+
982
+ retryWithBackoff({
983
+ maxTries: 20,
984
+ interval: 1_000,
985
+ async handler() {
986
+ const sql = postgres(appConfig.database.url);
987
+ await sql`SELECT 1`;
988
+ return sql;
989
+ },
990
+ });
991
+ ```
992
+
993
+ ## Rob's notes
994
+
995
+ - should exposing `appConfig` be a best practice?
996
+ - `core` tests are deno because it's hard to do both and Deno is more web-standards based
997
+ - json schema for configuration specs?
998
+ - note or info about loading dot-env files
999
+ - explain functional approach more and use of it instead of middleware
1000
+ - `defineRouteGroup` type primative for grouping routes together
1001
+ - Something like a `res/` directory of files loaded into memory for use
1002
+ - Migration logging to stdout
1003
+ - Can configuration be done without superstruct?
1004
+ - Improve the Migrator API, e.g. run "n" migrations or an external set