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/core/http.js ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @typedef {"GET"|"HEAD"|"POST"|"PUT"|"PATCH"|"DELETE"|"CONNECT"} HTTPMethod
3
+ */
4
+
5
+ /**
6
+ * @typedef {object} RouteContext
7
+ * @property {Request} request
8
+ * @property {URL} url
9
+ * @property {Record<string, string>} params
10
+ */
11
+
12
+ /**
13
+ * @typedef {(context: RouteContext) => Response | Promise<Response>} RouteHandler
14
+ */
15
+
16
+ /**
17
+ * @typedef {object} RouteOptions
18
+ * @property {HTTPMethod} method
19
+ * @property {pathname} pathname
20
+ * @property {RouteHandler} handler
21
+ */
22
+
23
+ /**
24
+ * @typedef {object} RouteDefinition
25
+ * @property {HTTPMethod} method
26
+ * @property {URLPattern} pattern
27
+ * @property {RouteHandler} handler
28
+ */
29
+
30
+ /**
31
+ * @param {RouteOptions} options
32
+ * @returns {RouteDefinition}
33
+ */
34
+ export function defineRoute(options) {
35
+ return {
36
+ method: options.method,
37
+ pattern: new URLPattern({ pathname: options.pathname }),
38
+ handler: options.handler,
39
+ };
40
+ }
41
+
42
+ // NOTE: add more error codes as needed
43
+ // NOTE: design an API for setting the body on static errors
44
+ export class HTTPError extends Error {
45
+ /** https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400 */
46
+ static badRequest() {
47
+ return new HTTPError(400, "Bad Request");
48
+ }
49
+
50
+ /** https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401 */
51
+ static unauthorized() {
52
+ return new HTTPError(401, "Unauthorized");
53
+ }
54
+
55
+ /** https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404 */
56
+ static notFound() {
57
+ return new HTTPError(404, "Not Found");
58
+ }
59
+
60
+ /** https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500 */
61
+ static internalServerError() {
62
+ return new HTTPError(500, "Internal Server Error");
63
+ }
64
+
65
+ /** https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/501 */
66
+ static notImplemented() {
67
+ return new HTTPError(501, "Not Implemented");
68
+ }
69
+
70
+ /** @type {number} */ status;
71
+ /** @type {string}*/ statusText;
72
+
73
+ /**
74
+ * @param {number} status
75
+ * @param {string} statusText
76
+ */
77
+ constructor(status = 200, statusText = "OK") {
78
+ super(statusText);
79
+ this.status = status;
80
+ this.statusText = statusText;
81
+ this.name = "HTTPError";
82
+ Error.captureStackTrace(this, HTTPError);
83
+ }
84
+
85
+ toResponse() {
86
+ return new Response(null, {
87
+ status: this.status,
88
+ statusText: this.statusText,
89
+ });
90
+ }
91
+ }
@@ -0,0 +1,68 @@
1
+ import { assertInstanceOf, assertEquals } from "./test-deps.js";
2
+ import { defineRoute, HTTPError } from "./http.js";
3
+
4
+ Deno.test("defineRoute", async (t) => {
5
+ await t.step("sets the method", () => {
6
+ const result = defineRoute({
7
+ method: "GET",
8
+ pathname: "/",
9
+ handler() {},
10
+ });
11
+ assertEquals(result.method, "GET");
12
+ });
13
+
14
+ await t.step("creates a URLPattern", () => {
15
+ const result = defineRoute({
16
+ method: "GET",
17
+ pathname: "/hello/:name",
18
+ handler() {},
19
+ });
20
+ assertInstanceOf(result.pattern, URLPattern);
21
+ assertEquals(result.pattern.pathname, "/hello/:name");
22
+ });
23
+ });
24
+
25
+ Deno.test("HTTPError", async (t) => {
26
+ await t.step("constructor", () => {
27
+ const result = new HTTPError(418, "I'm a teapot");
28
+ assertEquals(result.status, 418);
29
+ assertEquals(result.statusText, "I'm a teapot");
30
+ assertEquals(result.name, "HTTPError");
31
+ });
32
+
33
+ await t.step("toResponse", () => {
34
+ const result = new HTTPError(200, "OK").toResponse();
35
+ assertEquals(result.status, 200);
36
+ assertEquals(result.statusText, "OK");
37
+ });
38
+
39
+ await t.step("badRequest", () => {
40
+ const result = HTTPError.badRequest("body");
41
+ assertEquals(result.status, 400);
42
+ assertEquals(result.statusText, "Bad Request");
43
+ });
44
+
45
+ await t.step("unauthorized", () => {
46
+ const result = HTTPError.unauthorized();
47
+ assertEquals(result.status, 401);
48
+ assertEquals(result.statusText, "Unauthorized");
49
+ });
50
+
51
+ await t.step("notFound", () => {
52
+ const result = HTTPError.notFound();
53
+ assertEquals(result.status, 404);
54
+ assertEquals(result.statusText, "Not Found");
55
+ });
56
+
57
+ await t.step("internalServerError", () => {
58
+ const result = HTTPError.internalServerError();
59
+ assertEquals(result.status, 500);
60
+ assertEquals(result.statusText, "Internal Server Error");
61
+ });
62
+
63
+ await t.step("notImplemented", () => {
64
+ const result = HTTPError.notImplemented();
65
+ assertEquals(result.status, 501);
66
+ assertEquals(result.statusText, "Not Implemented");
67
+ });
68
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @template [T=unknown]
3
+ * @typedef {object} MigrationOptions
4
+ * @property {(value: T) => void | Promise<void>} up
5
+ * @property {(value: T) => void | Promise<void>} down
6
+ */
7
+
8
+ /**
9
+ * @template [T=unknown]
10
+ * @typedef {object} MigrationDefinition
11
+ * @property {string} name
12
+ * @property {(value: T) => void | Promise<void>} up
13
+ * @property {(value: T) => void | Promise<void>} down
14
+ */
15
+
16
+ /**
17
+ * @typedef {object} MigrationRecord
18
+ * @property {string} name
19
+ */
20
+
21
+ /**
22
+ * @template T @param {MigrationOptions<T>} options
23
+ * @returns {MigrationOptions<T>}
24
+ */
25
+ export function defineMigration(options) {
26
+ return {
27
+ up: options.up,
28
+ down: options.down,
29
+ };
30
+ }
31
+
32
+ /**
33
+ * @template T
34
+ * @typedef {object} MigratorOptions
35
+ * @property {() => Promise<MigrationDefinition<T>[]>} getDefinitions
36
+ * @property {() => Promise<MigrationRecord[]>} getRecords
37
+ * @property {(def: MigrationDefinition<T>, direction: "up"|"down") => void | Promise<void>} execute
38
+ */
39
+
40
+ /** @template T */
41
+ export class Migrator {
42
+ /** @param {MigratorOptions<T>} options */
43
+ constructor(options) {
44
+ this.options = options;
45
+ }
46
+
47
+ async up() {
48
+ for (const def of await this._getTodo("up", -1)) {
49
+ await this.options.execute(def, "up");
50
+ }
51
+ }
52
+
53
+ async down() {
54
+ for (const def of await this._getTodo("down", -1)) {
55
+ await this.options.execute(def, "down");
56
+ }
57
+ }
58
+
59
+ async _getTodo(direction, count) {
60
+ const defs = await this.options.getDefinitions();
61
+ const records = await this.options.getRecords();
62
+ const ran = new Set(records.map((r) => r.name));
63
+
64
+ return (direction === "up" ? defs : Array.from(defs).reverse())
65
+ .filter((def) => ran.has(def.name) === (direction === "down"))
66
+ .slice(0, count === -1 ? Infinity : count);
67
+ }
68
+ }
@@ -0,0 +1,116 @@
1
+ import { Migrator, defineMigration } from "./migrator.js";
2
+ import { assertEquals } from "./test-deps.js";
3
+
4
+ const bareOptions = {
5
+ getDefinitions: () => [],
6
+ getRecords: () => [],
7
+ executeUp() {},
8
+ executeDown() {},
9
+ };
10
+
11
+ Deno.test("defineMigration", async ({ step }) => {
12
+ await step("formats the options", () => {
13
+ const result = defineMigration({
14
+ up() {},
15
+ down() {},
16
+ });
17
+ assertEquals(result, {
18
+ up: result.up,
19
+ down: result.down,
20
+ });
21
+ });
22
+ });
23
+
24
+ Deno.test("Migrator", async ({ step }) => {
25
+ await step("_getTodo", async ({ step }) => {
26
+ await step("gets pending", async () => {
27
+ const migrator = new Migrator({
28
+ ...bareOptions,
29
+ getDefinitions: () => [{ name: "a" }, { name: "b" }, { name: "c" }],
30
+ });
31
+
32
+ const result = await migrator._getTodo("up", -1);
33
+ assertEquals(result, [{ name: "a" }, { name: "b" }, { name: "c" }]);
34
+ });
35
+ await step("gets executed", async () => {
36
+ const migrator = new Migrator({
37
+ ...bareOptions,
38
+ getDefinitions: () => [{ name: "a" }, { name: "b" }, { name: "c" }],
39
+ getRecords: () => [{ name: "a" }, { name: "b" }, { name: "c" }],
40
+ });
41
+
42
+ const result = await migrator._getTodo("down", -1);
43
+ assertEquals(result, [{ name: "c" }, { name: "b" }, { name: "a" }]);
44
+ });
45
+ await step("skips previous", async () => {
46
+ const migrator = new Migrator({
47
+ ...bareOptions,
48
+ getDefinitions: () => [{ name: "a" }, { name: "b" }, { name: "c" }],
49
+ getRecords: () => [{ name: "a" }],
50
+ });
51
+
52
+ const result = await migrator._getTodo("up", -1);
53
+ assertEquals(result, [{ name: "b" }, { name: "c" }]);
54
+ });
55
+ await step("limits up", async () => {
56
+ const migrator = new Migrator({
57
+ ...bareOptions,
58
+ getDefinitions: () => [{ name: "a" }, { name: "b" }, { name: "c" }],
59
+ });
60
+
61
+ const result = await migrator._getTodo("up", 1);
62
+ assertEquals(result, [{ name: "a" }]);
63
+ });
64
+ await step("limits down", async () => {
65
+ const migrator = new Migrator({
66
+ ...bareOptions,
67
+ getDefinitions: () => [{ name: "a" }, { name: "b" }, { name: "c" }],
68
+ getRecords: () => [{ name: "a" }, { name: "b" }, { name: "c" }],
69
+ });
70
+
71
+ const result = await migrator._getTodo("down", 1);
72
+ assertEquals(result, [{ name: "c" }]);
73
+ });
74
+ });
75
+
76
+ await step("up", async ({ step }) => {
77
+ await step("runs migrations", async () => {
78
+ const result = [];
79
+
80
+ const migrator = new Migrator({
81
+ ...bareOptions,
82
+ getDefinitions: () => [
83
+ { name: "a", up: () => result.push(1) },
84
+ { name: "b", up: () => result.push(2) },
85
+ { name: "c", up: () => result.push(3) },
86
+ ],
87
+ executeUp: (_, fn) => fn(),
88
+ });
89
+
90
+ await migrator.up();
91
+
92
+ assertEquals(result, [1, 2, 3]);
93
+ });
94
+ });
95
+
96
+ await step("down", async ({ step }) => {
97
+ await step("runs migrations", async () => {
98
+ const result = [];
99
+
100
+ const migrator = new Migrator({
101
+ ...bareOptions,
102
+ getDefinitions: () => [
103
+ { name: "a", down: () => result.push(1) },
104
+ { name: "b", down: () => result.push(2) },
105
+ { name: "c", down: () => result.push(3) },
106
+ ],
107
+ getRecords: () => [{ name: "a" }, { name: "b" }, { name: "c" }],
108
+ executeDown: (_, fn) => fn(),
109
+ });
110
+
111
+ await migrator.down();
112
+
113
+ assertEquals(result, [3, 2, 1]);
114
+ });
115
+ });
116
+ });
package/core/mod.js ADDED
@@ -0,0 +1,6 @@
1
+ export * from "./configuration.js";
2
+ export * from "./fetch-router.js";
3
+ export * from "./http.js";
4
+ export * from "./migrator.js";
5
+ export * from "./postgres.js";
6
+ export * from "./utilities.js";
@@ -0,0 +1,92 @@
1
+ import { defineMigration } from "./migrator.js";
2
+
3
+ /** @typedef {import("postgres").Sql} Sql */
4
+ /** @typedef {import("./migrator.js").MigratorOptions} MigratorOptions */
5
+ /** @typedef {import("./migrator.js").MigrationRecord} MigrationRecord */
6
+ /** @typedef {import("./migrator.js").MigrationDefinition} MigrationDefinition */
7
+
8
+ /**
9
+ @param {Sql} sql
10
+ @returns {Promise<MigrationRecord[]>}
11
+ */
12
+ export async function _getMigrationRecords(sql) {
13
+ try {
14
+ const rows = await sql`
15
+ SELECT name, created
16
+ FROM migrations
17
+ `;
18
+ return rows.map(({ name }) => ({ name }));
19
+ } catch {
20
+ return [];
21
+ }
22
+ }
23
+
24
+ /**
25
+ * @param {MigrationDefinition} def
26
+ * @param {"up" | "down"} direction
27
+ * @param {Sql} sql
28
+ */
29
+ export function _execute(def, direction, sql) {
30
+ return sql.begin((sql) => {
31
+ console.log("migrate %s", direction, def.name);
32
+ if (direction === "up") return _executeUp(def, sql);
33
+ if (direction === "down") return _executeDown(def, sql);
34
+ });
35
+ }
36
+
37
+ /**
38
+ * @param {MigrationDefinition} def
39
+ * @param {Sql} sql
40
+ */
41
+ export async function _executeUp(def, sql) {
42
+ await def.up(sql);
43
+
44
+ await sql`
45
+ INSERT INTO migrations (name) VALUES (${def.name})
46
+ `;
47
+ }
48
+
49
+ /**
50
+ * @param {MigrationDefinition} def
51
+ * @param {Sql} sql
52
+ */
53
+ export async function _executeDown(def, sql) {
54
+ await def.down(sql);
55
+
56
+ if (def.down !== bootstrapMigration.down) {
57
+ await sql`DELETE FROM migrations WHERE name = ${def.name}`;
58
+ }
59
+ }
60
+
61
+ export const bootstrapMigration = defineMigration({
62
+ async up(sql) {
63
+ await sql`
64
+ CREATE TABLE "migrations" (
65
+ "name" varchar(255) PRIMARY KEY,
66
+ "created" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL
67
+ )
68
+ `;
69
+ },
70
+ async down(sql) {
71
+ await sql`
72
+ DROP TABLE "migrations"
73
+ `;
74
+ },
75
+ });
76
+
77
+ /**
78
+ * @typedef {object} PostgresMigratorOptions
79
+ * @property {Sql} sql
80
+ */
81
+
82
+ /**
83
+ * @param {PostgresMigratorOptions} options
84
+ * @returns {MigratorOptions<Sql>}
85
+ */
86
+ export function getPostgresMigratorOptions(options) {
87
+ return {
88
+ getRecords: () => _getMigrationRecords(options.sql),
89
+ execute: (def, direction) => _execute(def, direction, options.sql),
90
+ getDefinitions: () => [bootstrapMigration],
91
+ };
92
+ }
@@ -0,0 +1,2 @@
1
+ export * from "https://deno.land/std@0.211.0/assert/mod.ts";
2
+ export * as superstruct from "npm:superstruct@^1.0.3";
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @template T @param {T} fields
3
+ * @param {(keyof T)[]} columns
4
+ * @param {string} fallback
5
+ */
6
+ export function formatMarkdownTable(fields, columns, fallback) {
7
+ const widths = columns.map((column) => {
8
+ let largest = column.length;
9
+ for (const field of fields) {
10
+ if (field[column] && field[column].length > largest) {
11
+ largest = field[column].length;
12
+ }
13
+ }
14
+ return largest;
15
+ });
16
+
17
+ const lines = [
18
+ // Header
19
+ "| " + columns.map((n, i) => n.padEnd(widths[i]), " ").join(" | ") + " |",
20
+
21
+ // Seperator
22
+ "| " + columns.map((_, i) => "=".padEnd(widths[i], "=")).join(" | ") + " |",
23
+
24
+ // Values
25
+ ...fields.map(
26
+ (field) =>
27
+ "| " +
28
+ columns
29
+ .map((n, i) => (field[n] ?? fallback).padEnd(widths[i], " "))
30
+ .join(" | ") +
31
+ " |",
32
+ ),
33
+ ];
34
+ return lines.join("\n");
35
+ }
36
+
37
+ /**
38
+ * @template T @param {() => T} handler
39
+ */
40
+ export function loader(handler) {
41
+ /** @type {T | null} */
42
+ let result = null;
43
+ return () => {
44
+ if (result === null) result = handler();
45
+ return result;
46
+ };
47
+ }
@@ -0,0 +1,34 @@
1
+ import { formatMarkdownTable } from "./utilities.js";
2
+ import { assertEquals } from "./test-deps.js";
3
+
4
+ const expected = `
5
+ | name | type | argument | variable | fallback |
6
+ | ======= | ====== | ========== | ======== | ===================== |
7
+ | env | string | ~ | NODE_ENV | development |
8
+ | selfUrl | url | --self-url | ~ | http://localhost:3000 |
9
+ `.trim();
10
+
11
+ Deno.test("formatMarkdownTable", async ({ step }) => {
12
+ await step("returns a table", () => {
13
+ const result = formatMarkdownTable(
14
+ [
15
+ {
16
+ name: "env",
17
+ type: "string",
18
+ variable: "NODE_ENV",
19
+ fallback: "development",
20
+ },
21
+ {
22
+ name: "selfUrl",
23
+ type: "url",
24
+ argument: "--self-url",
25
+ variable: "~",
26
+ fallback: "http://localhost:3000",
27
+ },
28
+ ],
29
+ ["name", "type", "argument", "variable", "fallback"],
30
+ "~",
31
+ );
32
+ assertEquals(result, expected);
33
+ });
34
+ });
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "gruber",
3
+ "type": "module",
4
+ "dependencies": {
5
+ "superstruct": "^1.0.3",
6
+ "urlpattern-polyfill": "^9.0.0"
7
+ },
8
+ "devDependencies": {
9
+ "@types/express": "^4.17.21",
10
+ "@types/koa": "^2.14.0",
11
+ "@types/node": "^20.10.8"
12
+ },
13
+ "exports": {
14
+ ".": {
15
+ "import": "./source/mod.js"
16
+ },
17
+ "./*.js": {
18
+ "import": "./source/*.js"
19
+ }
20
+ },
21
+ "version": "0.1.0"
22
+ }
@@ -0,0 +1,50 @@
1
+ import fs from "node:fs";
2
+ import process from "node:process";
3
+ import util from "node:util";
4
+
5
+ import { Configuration } from "../core/configuration.js";
6
+
7
+ export { Configuration };
8
+
9
+ /**
10
+ @typedef {object} NodeConfigurationOptions
11
+ @property {import("superstruct")} superstruct
12
+ */
13
+
14
+ /** @param {NodeConfigurationOptions} options */
15
+ export function getNodeConfigOptions(options) {
16
+ const args = util.parseArgs({
17
+ args: process.args,
18
+ strict: false,
19
+ });
20
+ return {
21
+ superstruct: options.superstruct,
22
+ async readTextFile(url) {
23
+ try {
24
+ return await fs.promises.readFile(url);
25
+ } catch (error) {
26
+ return null;
27
+ }
28
+ },
29
+ getEnvironmentVariable(key) {
30
+ return process.env[key];
31
+ },
32
+ getCommandArgument(key) {
33
+ return args.values[key.replace(/^-+/, "")];
34
+ },
35
+ stringify(config) {
36
+ return JSON.stringify(config, null, 2);
37
+ },
38
+ parse(data) {
39
+ return JSON.parse(data);
40
+ },
41
+ };
42
+ }
43
+
44
+ /**
45
+ * This is a syntax sugar for `new Configuration(getNodeConfigOptions(options))`
46
+ * @param {NodeConfigurationOptions} options
47
+ */
48
+ export function getNodeConfiguration(options) {
49
+ return new Configuration(getNodeConfigOptions(options));
50
+ }
package/source/core.js ADDED
@@ -0,0 +1 @@
1
+ export * from "../core/mod.js";
@@ -0,0 +1,55 @@
1
+ import { Readable } from "node:stream";
2
+
3
+ import { FetchRouter } from "../core/fetch-router.js";
4
+ import { getFetchRequest } from "./node-router.js";
5
+
6
+ /**
7
+ A HTTP router for Express applications
8
+
9
+ ```js
10
+ const router = new ExpressRouter(...)
11
+
12
+ const app = express()
13
+ .use(...)
14
+ .use(router.middleware());
15
+ .use(...)
16
+ ```
17
+ */
18
+ export class ExpressRouter {
19
+ /** @param {NodeRouterOptions} options */
20
+ constructor(options = {}) {
21
+ this.router = new FetchRouter(options.routes ?? []);
22
+ }
23
+
24
+ /** @returns {import("express").RequestHandler} */
25
+ middleware() {
26
+ return async (req, res, next) => {
27
+ const request = getFetchRequest(req);
28
+ const response = await this.getResponse(request);
29
+ this.respond(res, response);
30
+ next();
31
+ };
32
+ }
33
+
34
+ /** @param {Request} request */
35
+ getResponse(request) {
36
+ return this.router.getResponse(request);
37
+ }
38
+
39
+ /**
40
+ @param {import("express").Response} res
41
+ @param {Response} response
42
+ */
43
+ respond(res, response) {
44
+ res.statusCode = response.status;
45
+ res.statusMessage = response.statusMessage;
46
+
47
+ for (const [key, value] of response.headers) {
48
+ const values = value.split(",");
49
+ res.set(key, values.length === 1 ? value : values);
50
+ }
51
+
52
+ if (response.body) Readable.fromWeb(response.body).pipe(res);
53
+ else res.end();
54
+ }
55
+ }