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/CHANGELOG.md +7 -0
- package/README.md +1004 -0
- package/core/configuration.js +182 -0
- package/core/configuration.test.js +237 -0
- package/core/fetch-router.js +82 -0
- package/core/fetch-router.test.js +128 -0
- package/core/http.js +91 -0
- package/core/http.test.js +68 -0
- package/core/migrator.js +68 -0
- package/core/migrator.test.js +116 -0
- package/core/mod.js +6 -0
- package/core/postgres.js +92 -0
- package/core/test-deps.js +2 -0
- package/core/utilities.js +47 -0
- package/core/utilities.test.js +34 -0
- package/package.json +22 -0
- package/source/configuration.js +50 -0
- package/source/core.js +1 -0
- package/source/express-router.js +55 -0
- package/source/koa-router.js +60 -0
- package/source/mod.js +6 -0
- package/source/node-router.js +83 -0
- package/source/package-lock.json +199 -0
- package/source/package.json +21 -0
- package/source/polyfill.js +2 -0
- package/source/postgres.js +75 -0
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
|
+
});
|
package/core/migrator.js
ADDED
|
@@ -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
package/core/postgres.js
ADDED
|
@@ -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,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
|
+
}
|