counterfact 0.0.1

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.
@@ -0,0 +1,8 @@
1
+ # Changesets
2
+
3
+ Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4
+ with multi-package repos, or single-package repos to help you version and publish your code. You can
5
+ find the full documentation for it [in our repository](https://github.com/changesets/changesets)
6
+
7
+ We have a quick list of common questions to get you started engaging with this project in
8
+ [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
@@ -0,0 +1,11 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@changesets/config@2.0.0/schema.json",
3
+ "changelog": "@changesets/cli/changelog",
4
+ "commit": false,
5
+ "fixed": [],
6
+ "linked": [],
7
+ "access": "restricted",
8
+ "baseBranch": "main",
9
+ "updateInternalDependencies": "patch",
10
+ "ignore": []
11
+ }
package/.eslintrc.cjs ADDED
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+
3
+ module.exports = {
4
+ extends: ["hardcore", "hardcore/ts", "hardcore/node"],
5
+
6
+ parserOptions: {
7
+ sourceType: "module",
8
+ },
9
+
10
+ rules: {
11
+ "putout/putout": "off",
12
+ "import/prefer-default-export": "off",
13
+ "@typescript-eslint/naming-convention": "off",
14
+
15
+ "max-len": [
16
+ "warn",
17
+ {
18
+ ignorePattern: "eslint|it\\(|describe\\(",
19
+ ignoreTemplateLiterals: true,
20
+ },
21
+ ],
22
+ },
23
+
24
+ overrides: [
25
+ {
26
+ files: ["*.cjs"],
27
+ extends: ["hardcore", "hardcore/node"],
28
+
29
+ rules: {
30
+ "import/no-commonjs": "off",
31
+ },
32
+
33
+ parserOptions: {
34
+ sourceType: "script",
35
+ },
36
+ },
37
+
38
+ {
39
+ files: ["*.test.js"],
40
+
41
+ extends: ["hardcore", "hardcore/ts", "hardcore/node", "hardcore/jest"],
42
+
43
+ rules: {
44
+ "putout/putout": "off",
45
+ "import/unambiguous": "off",
46
+ "jest/prefer-expect-assertions": "off",
47
+
48
+ "new-cap": [
49
+ "error",
50
+ { capIsNewExceptionPattern: "GET|PUT|POST|DELETE" },
51
+ ],
52
+
53
+ "@typescript-eslint/naming-convention": "off",
54
+
55
+ "max-len": [
56
+ "warn",
57
+ {
58
+ ignorePattern: "eslint|it\\(|describe\\(",
59
+ ignoreTemplateLiterals: true,
60
+ },
61
+ ],
62
+
63
+ "node/no-unpublished-import": "off",
64
+ },
65
+ },
66
+
67
+ {
68
+ files: ["demo/**/*.js"],
69
+ extends: ["hardcore", "hardcore/node"],
70
+
71
+ rules: {
72
+ "import/no-extraneous-dependencies": "off",
73
+ "import/no-unused-modules": "off",
74
+ "import/prefer-default-export": "off",
75
+ "no-param-reassign": "off",
76
+ "no-console": "off",
77
+ "node/no-unpublished-import": "off",
78
+ },
79
+ },
80
+ ],
81
+ };
@@ -0,0 +1,46 @@
1
+ name: CI Checks
2
+
3
+ on:
4
+ pull_request:
5
+ branches:
6
+ - main
7
+ workflow_dispatch:
8
+
9
+ concurrency: ${{ github.workflow }}-${{ github.ref }}
10
+
11
+ jobs:
12
+ ci-checks:
13
+ name: CI Checks
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - name: Check out repo
17
+ uses: actions/checkout@v3
18
+ with:
19
+ fetch-depth: 0
20
+ - uses: actions/setup-node@v3
21
+ with:
22
+ node-version: 16.x
23
+ cache: yarn
24
+ id: setup-node
25
+ - name: Get Node Version
26
+ run: echo "::set-output name=version::$(node -v)"
27
+ id: node-version
28
+ - name: Cache node_modules
29
+ uses: actions/cache@v3
30
+ with:
31
+ path: "**/node_modules"
32
+ key: ${{ runner.os }}-${{ steps.node-version.outputs.version }}-${{ hashFiles('**/yarn.lock') }}
33
+ - name: Install Packages
34
+ run: yarn install --frozen-lockfile
35
+ - name: ESlint
36
+ run: yarn eslint -f github-annotations .
37
+ - name: Unit tests
38
+ run: yarn test --reporters=github-actions
39
+ - name: Create Release Pull Request or Publish to npm
40
+ id: changesets
41
+ uses: changesets/action@v1
42
+ with:
43
+ publish: yarn release
44
+ env:
45
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
package/.putout.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "rules": {
3
+ "strict-mode/add": "off"
4
+ }
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "jest.nodeEnv": {
3
+ "NODE_OPTIONS": "--experimental-vm-modules"
4
+ }
5
+ }
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # Counterfact
2
+
3
+ This is a work in progress.
4
+
5
+ If you're nosy and don't mind a whole lot of incompleteness:
6
+
7
+ ```sh
8
+ yarn
9
+ yarn test
10
+ node demo/index.js
11
+ ```
12
+
13
+ Then open http://localhost:3100/hello/world and prepare to be underwhelmed. (See [./demo/README.md](./demo/README.md) for more.)
14
+
15
+ ## Features
16
+
17
+ - [x] Convention-over-configuration style API
18
+ - [x] un-opinionated with respect how you set up fake data and APIs
19
+ - [x] e.g. implement `GET /hello/world` by exporting a `GET()` function from `./hello/world.mjs`
20
+ - [ ] easily read the request (method, headers, body, path, query string) and send a response (status, headers, body, content type, etc.)
21
+ - [x] fundamental, most commonly used parts
22
+ - [ ] details
23
+ - [x] middleware to plug in to Koa
24
+ - [ ] can also be run as a stand-alone HTTP server via CLI
25
+ - [x] hot reload: add / remove / change a file and see results without restarting the server
26
+ - [x] convenient access to a store which has local state
27
+ - [x] pure API (using reducers)
28
+ - [x] side effect API
29
+ - [ ] set up test scenarios by populating a store using "migration scripts"
30
+ - [ ] configure the store through an API or a GUI to select migration scripts
31
+ - [ ] "record mode" to automatically generate migration scripts
32
+
33
+ ## Non-functional requirements
34
+
35
+ - [x] Written in Typescript
36
+ - [x] Solid unit test coverage
37
+ - [ ] CI/CD pipeline
38
+ - [ ] Documentation
39
+ - [x] Demo
40
+ - [ ] Publish to npm
41
+
42
+ As of Friday, April 15, 2022, all of the "hard" stuff is done (bleeding edge Node + Jest + TypeScript and most of hot reloading) and what remains is mostly drudgery. I'm aiming to finish hot reloading and put together a demo by EOD Monday (and hopefully more check some more boxes).
package/demo/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # Counterfact Demo
2
+
3
+ This demo illustrates some of the basic features of Counterfact.
4
+
5
+ `index.js` starts an Apollo server and loads Counterfact's middleware pointed to route definitions at `./routes`.
6
+
7
+ Under `./routes` you will find a few endpoints definitions:
8
+
9
+ `hello.js` defines `/hello/:friend` and says hello to your friends.
10
+
11
+ `hello/kitty.js` defines `/hello/kitty`, overriding the behavior defined in `hello.js`.
12
+
13
+ `count.js` defines `/count` and reports how many times you've visited the other URLs.
14
+
15
+ Try adding more routes. You should be able to see the updates without restarting the server.
package/demo/index.js ADDED
@@ -0,0 +1,18 @@
1
+ import { fileURLToPath } from "node:url";
2
+
3
+ import Koa from "koa";
4
+
5
+ import { counterfact } from "../src/counterfact.js";
6
+
7
+ const PORT = 3100;
8
+
9
+ const app = new Koa();
10
+
11
+ const { koaMiddleware } = await counterfact(
12
+ fileURLToPath(new URL("routes/", import.meta.url))
13
+ );
14
+
15
+ app.use(koaMiddleware);
16
+
17
+ app.listen(PORT);
18
+ console.log(`Open http://localhost:${PORT}/hello/world`);
@@ -0,0 +1,13 @@
1
+ export function GET({ store }) {
2
+ if (!store.visits) {
3
+ return {
4
+ body: "You have not visited anyone yet.",
5
+ };
6
+ }
7
+
8
+ return {
9
+ body: Object.entries(store.visits)
10
+ .map(([page, count]) => `You visited ${page} ${count} times.`)
11
+ .join("\n"),
12
+ };
13
+ }
@@ -0,0 +1,5 @@
1
+ export function GET({ id }) {
2
+ return {
3
+ body: id,
4
+ };
5
+ }
@@ -0,0 +1,5 @@
1
+ export function GET() {
2
+ return {
3
+ body: '<img src="https://upload.wikimedia.org/wikipedia/en/0/05/Hello_kitty_character_portrait.png">',
4
+ };
5
+ }
@@ -0,0 +1,13 @@
1
+ export function GET({ path, store, query }) {
2
+ store.visits ??= {};
3
+ store.visits[path] ??= 0;
4
+ store.visits[path] += 1;
5
+
6
+ if (!path) {
7
+ return { body: "Hello, stranger!" };
8
+ }
9
+
10
+ return {
11
+ body: `${query.greeting ?? "Hello"}, ${path}!`,
12
+ };
13
+ }
package/index.js ADDED
@@ -0,0 +1,4 @@
1
+ const greeting = "hello";
2
+ const name = "world";
3
+
4
+ export { greeting, name };
package/jest.config.js ADDED
@@ -0,0 +1,6 @@
1
+ /* eslint-disable import/no-anonymous-default-export */
2
+
3
+ /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
4
+ export default {
5
+ testEnvironment: "node",
6
+ };
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "counterfact",
3
+ "version": "0.0.1",
4
+ "description": "a library for building a fake REST API for testing",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "author": "Patrick McElhaney <pmcelhaney@gmail.com>",
8
+ "license": "MIT",
9
+ "engines": {
10
+ "node": ">=16.9.0"
11
+ },
12
+ "scripts": {
13
+ "test": "yarn node --experimental-vm-modules $(yarn bin jest)"
14
+ },
15
+ "devDependencies": {
16
+ "eslint": "8.17.0",
17
+ "eslint-config-hardcore": "24.5.0",
18
+ "eslint-formatter-github-annotations": "0.1.0",
19
+ "jest": "28.1.0",
20
+ "koa": "2.13.4",
21
+ "nodemon": "2.0.16",
22
+ "supertest": "6.2.3",
23
+ "typescript": "4.7.3"
24
+ },
25
+ "dependencies": {
26
+ "@changesets/cli": "^2.22.0",
27
+ "chokidar": "^3.5.3"
28
+ }
29
+ }
package/renovate.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": [
3
+ "config:base"
4
+ ]
5
+ }
@@ -0,0 +1,15 @@
1
+ import { Registry } from "./registry.js";
2
+ import { Dispatcher } from "./dispatcher.js";
3
+ import { koaMiddleware } from "./koa-middleware.js";
4
+ import { ModuleLoader } from "./module-loader.js";
5
+
6
+ export async function counterfact(basePath) {
7
+ const registry = new Registry();
8
+ const dispatcher = new Dispatcher(registry);
9
+ const moduleLoader = new ModuleLoader(basePath, registry);
10
+
11
+ await moduleLoader.load();
12
+ await moduleLoader.watch();
13
+
14
+ return { koaMiddleware: koaMiddleware(dispatcher), registry, moduleLoader };
15
+ }
@@ -0,0 +1,24 @@
1
+ export class Dispatcher {
2
+ registry;
3
+
4
+ constructor(registry) {
5
+ this.registry = registry;
6
+ }
7
+
8
+ request({ method, path, body, query }) {
9
+ return this.registry.endpoint(
10
+ method,
11
+ path
12
+ )({
13
+ // path: parts.slice(remainingParts).join("/"),
14
+
15
+ reduce: (reducer) => {
16
+ this.registry.store = reducer(this.registry.store);
17
+ },
18
+
19
+ store: this.registry.store,
20
+ body,
21
+ query,
22
+ });
23
+ }
24
+ }
@@ -0,0 +1,17 @@
1
+ export function koaMiddleware(dispatcher) {
2
+ return async function middleware(ctx) {
3
+ const { method, path, body, query } = ctx.request;
4
+
5
+ const response = await dispatcher.request({
6
+ method,
7
+ path,
8
+ body,
9
+ query,
10
+ });
11
+
12
+ /* eslint-disable require-atomic-updates */
13
+ ctx.body = response.body;
14
+ ctx.status = 200;
15
+ /* eslint-enable require-atomic-updates */
16
+ };
17
+ }
@@ -0,0 +1,85 @@
1
+ /* eslint-disable node/no-unsupported-features/es-syntax */
2
+ /* eslint-disable import/no-dynamic-require */
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import EventEmitter, { once } from "node:events";
6
+
7
+ import chokidar from "chokidar";
8
+
9
+ export class ModuleLoader extends EventEmitter {
10
+ basePath;
11
+
12
+ registry;
13
+
14
+ watcher;
15
+
16
+ constructor(basePath, registry) {
17
+ super();
18
+ this.basePath = basePath;
19
+ this.registry = registry;
20
+ }
21
+
22
+ async watch() {
23
+ if (this.watcher) {
24
+ throw new Error("already watching");
25
+ }
26
+
27
+ this.watcher = chokidar
28
+ .watch(`${this.basePath}/**/*`)
29
+ .on("all", (event, pathName) => {
30
+ if (!["add", "change", "unlink"].includes(event)) {
31
+ return;
32
+ }
33
+
34
+ const parts = path.parse(pathName.replace(this.basePath, ""));
35
+ const url = `/${path.join(parts.dir, parts.name)}`;
36
+
37
+ if (event === "unlink") {
38
+ this.registry.remove(url);
39
+ this.emit("remove", pathName);
40
+ }
41
+
42
+ import(`${pathName}?cacheBust=${Date.now()}`)
43
+ // eslint-disable-next-line promise/prefer-await-to-then
44
+ .then((endpoint) => {
45
+ this.registry.add(url, endpoint);
46
+ this.emit(event, pathName);
47
+
48
+ return "ok";
49
+ })
50
+ // eslint-disable-next-line promise/prefer-await-to-then
51
+ .catch((error) => {
52
+ throw new Error(String(error));
53
+ });
54
+ });
55
+ await once(this.watcher, "ready");
56
+ }
57
+
58
+ async stopWatching() {
59
+ await this.watcher?.close();
60
+ }
61
+
62
+ async load(directory = "") {
63
+ const files = await fs.readdir(path.join(this.basePath, directory), {
64
+ withFileTypes: true,
65
+ });
66
+ const imports = files.flatMap(async (file) => {
67
+ if (file.isDirectory()) {
68
+ await this.load(path.join(directory, file.name));
69
+
70
+ return;
71
+ }
72
+
73
+ const endpoint = await import(
74
+ path.join(this.basePath, directory, file.name)
75
+ );
76
+
77
+ this.registry.add(
78
+ `/${path.join(directory, path.parse(file.name).name)}`,
79
+ endpoint
80
+ );
81
+ });
82
+
83
+ await Promise.all(imports);
84
+ }
85
+ }
@@ -0,0 +1,87 @@
1
+ export class Registry {
2
+ modules = {};
3
+
4
+ moduleTree = {
5
+ children: {},
6
+ };
7
+
8
+ store;
9
+
10
+ constructor(store = {}) {
11
+ this.store = store;
12
+ }
13
+
14
+ get modulesList() {
15
+ return Object.keys(this.modules);
16
+ }
17
+
18
+ add(url, module) {
19
+ let node = this.moduleTree;
20
+
21
+ for (const segment of url.split("/").slice(1)) {
22
+ node.children ??= {};
23
+ node.children[segment] ??= {};
24
+ node = node.children[segment];
25
+ }
26
+
27
+ node.module = module;
28
+ }
29
+
30
+ remove(url) {
31
+ let node = this.moduleTree;
32
+
33
+ for (const segment of url.split("/").slice(1)) {
34
+ node = node?.children?.[segment];
35
+
36
+ if (!node) {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ delete node.module;
42
+
43
+ return true;
44
+ }
45
+
46
+ exists(method, url) {
47
+ return Boolean(this.handler(url)?.module?.[method]);
48
+ }
49
+
50
+ handler(url) {
51
+ let node = this.moduleTree;
52
+
53
+ const path = {};
54
+
55
+ for (const segment of url.split("/").slice(1)) {
56
+ if (node.children[segment]) {
57
+ node = node.children[segment];
58
+ } else {
59
+ const dynamicSegment = Object.keys(node.children).find(
60
+ (ds) => ds.startsWith("[") && ds.endsWith("]")
61
+ );
62
+
63
+ if (dynamicSegment) {
64
+ // eslint-disable-next-line no-magic-numbers
65
+ path[dynamicSegment.slice(1, -1)] = segment;
66
+
67
+ node = node.children[dynamicSegment];
68
+ }
69
+ }
70
+ }
71
+
72
+ return { module: node.module, path };
73
+ }
74
+
75
+ endpoint(httpRequestMethod, url) {
76
+ const handler = this.handler(url);
77
+ const lambda = handler?.module?.[httpRequestMethod];
78
+
79
+ if (!lambda) {
80
+ throw new Error(
81
+ `${httpRequestMethod} method for endpoint at "${url}" does not exist`
82
+ );
83
+ }
84
+
85
+ return ({ ...context }) => lambda({ ...context, path: handler.path });
86
+ }
87
+ }
@@ -0,0 +1,39 @@
1
+ import supertest from "supertest";
2
+ import Koa from "koa";
3
+
4
+ import { counterfact } from "../src/counterfact.js";
5
+
6
+ import { withTemporaryFiles } from "./lib/with-temporary-files.js";
7
+
8
+ describe("integration test", () => {
9
+ it("finds a path", async () => {
10
+ const app = new Koa();
11
+ const request = supertest(app.callback());
12
+ const files = {
13
+ "hello.mjs": `
14
+ export async function GET() {
15
+ return await Promise.resolve({ body: "GET /hello" });
16
+ }
17
+ `,
18
+ "hello/world.mjs": `
19
+ export async function POST() {
20
+ return await Promise.resolve({ body: "POST /hello/world" });
21
+ }
22
+ `,
23
+ };
24
+
25
+ await withTemporaryFiles(files, async (basePath) => {
26
+ const { koaMiddleware, moduleLoader } = await counterfact(basePath);
27
+
28
+ app.use(koaMiddleware);
29
+
30
+ const getResponse = await request.get("/hello");
31
+ const postResponse = await request.post("/hello/world");
32
+
33
+ expect(getResponse.text).toBe("GET /hello");
34
+ expect(postResponse.text).toBe("POST /hello/world");
35
+
36
+ await moduleLoader.stopWatching();
37
+ });
38
+ });
39
+ });
@@ -0,0 +1,143 @@
1
+ import { Dispatcher } from "../src/dispatcher.js";
2
+ import { Registry } from "../src/registry.js";
3
+
4
+ describe("a dispatcher", () => {
5
+ it("dispatches a get request to a server and returns the response", async () => {
6
+ const registry = new Registry();
7
+
8
+ registry.add("/hello", {
9
+ GET() {
10
+ return {
11
+ body: "hello",
12
+ };
13
+ },
14
+ });
15
+
16
+ const dispatcher = new Dispatcher(registry);
17
+ const response = await dispatcher.request({
18
+ method: "GET",
19
+ path: "/hello",
20
+ body: "",
21
+ });
22
+
23
+ expect(response.body).toBe("hello");
24
+ });
25
+
26
+ it("passes the request body", async () => {
27
+ const registry = new Registry();
28
+
29
+ registry.add("/a", {
30
+ GET({ body }) {
31
+ return {
32
+ body: `Hello ${body.name} of ${body.place}!`,
33
+ };
34
+ },
35
+ });
36
+
37
+ const dispatcher = new Dispatcher(registry);
38
+ const response = await dispatcher.request({
39
+ method: "GET",
40
+ path: "/a",
41
+
42
+ body: {
43
+ name: "Catherine",
44
+ place: "Aragon",
45
+ },
46
+ });
47
+
48
+ expect(response.body).toBe("Hello Catherine of Aragon!");
49
+ });
50
+
51
+ it("passes the query params", async () => {
52
+ const registry = new Registry();
53
+
54
+ registry.add("/a", {
55
+ GET({ query }) {
56
+ return {
57
+ body: `Searching for stores near ${query.zip}!`,
58
+ };
59
+ },
60
+ });
61
+
62
+ const dispatcher = new Dispatcher(registry);
63
+ const response = await dispatcher.request({
64
+ method: "GET",
65
+ path: "/a",
66
+
67
+ query: {
68
+ zip: "90210",
69
+ },
70
+ });
71
+
72
+ expect(response.body).toBe("Searching for stores near 90210!");
73
+ });
74
+
75
+ it("passes a reducer function that can be used to read / update the store", async () => {
76
+ const registry = new Registry({ value: 0 });
77
+
78
+ registry.add("/increment/[value]", {
79
+ GET({ reduce, path }) {
80
+ const amountToIncrement = Number.parseInt(path.value, 10);
81
+
82
+ reduce((store) => ({
83
+ value: store.value + amountToIncrement,
84
+ }));
85
+
86
+ return { body: "incremented" };
87
+ },
88
+ });
89
+
90
+ const dispatcher = new Dispatcher(registry);
91
+
92
+ await dispatcher.request({
93
+ method: "GET",
94
+ path: "/increment/1",
95
+ body: "",
96
+ });
97
+
98
+ expect(registry.store.value).toBe(1);
99
+
100
+ await dispatcher.request({
101
+ method: "GET",
102
+ path: "/increment/2",
103
+ body: "",
104
+ });
105
+
106
+ expect(registry.store.value).toBe(3);
107
+ });
108
+
109
+ it("allows the store to be mutated directly", async () => {
110
+ const registry = new Registry({ value: 0 });
111
+
112
+ registry.add("/increment/[value]", {
113
+ GET({ store, path }) {
114
+ const amountToIncrement = Number.parseInt(path.value, 10);
115
+
116
+ // eslint-disable-next-line no-param-reassign
117
+ store.value += amountToIncrement;
118
+
119
+ return { body: "incremented" };
120
+ },
121
+ });
122
+
123
+ const dispatcher = new Dispatcher(registry);
124
+
125
+ const result = await dispatcher.request({
126
+ method: "GET",
127
+ path: "/increment/1",
128
+ body: "",
129
+ });
130
+
131
+ expect(result.body).toBe("incremented");
132
+
133
+ expect(registry.store.value).toBe(1);
134
+
135
+ await dispatcher.request({
136
+ method: "GET",
137
+ path: "/increment/2",
138
+ body: "",
139
+ });
140
+
141
+ expect(registry.store.value).toBe(3);
142
+ });
143
+ });
@@ -0,0 +1,28 @@
1
+ import { Registry } from "../src/registry.js";
2
+ import { Dispatcher } from "../src/dispatcher.js";
3
+ import { koaMiddleware } from "../src/koa-middleware.js";
4
+
5
+ describe("koa middleware", () => {
6
+ it("passes the request to the dispatcher and returns the response", async () => {
7
+ const registry = new Registry();
8
+
9
+ registry.add("/hello", {
10
+ GET({ body }) {
11
+ return {
12
+ body: `Hello, ${body.name}!`,
13
+ };
14
+ },
15
+ });
16
+
17
+ const dispatcher = new Dispatcher(registry);
18
+ const middleware = koaMiddleware(dispatcher);
19
+ const ctx = {
20
+ request: { path: "/hello", method: "GET", body: { name: "Homer" } },
21
+ };
22
+
23
+ await middleware(ctx);
24
+
25
+ expect(ctx.status).toBe(200);
26
+ expect(ctx.body).toBe("Hello, Homer!");
27
+ });
28
+ });
@@ -0,0 +1,62 @@
1
+ import fs from "node:fs/promises";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ async function ensureDirectoryExists(filePath) {
7
+ const directory = path.dirname(filePath);
8
+
9
+ try {
10
+ await fs.access(directory, fsConstants.W_OK);
11
+ } catch {
12
+ await fs.mkdir(directory, { recursive: true });
13
+ }
14
+ }
15
+
16
+ function createAddFunction(basePath) {
17
+ return async function add(filePath, content) {
18
+ const fullPath = path.join(basePath, filePath);
19
+
20
+ await ensureDirectoryExists(fullPath);
21
+ await fs.writeFile(fullPath, content);
22
+ };
23
+ }
24
+
25
+ function createRemoveFunction(basePath) {
26
+ return async function remove(filePath) {
27
+ const fullPath = path.join(basePath, filePath);
28
+
29
+ await ensureDirectoryExists(fullPath);
30
+ await fs.rm(fullPath);
31
+ };
32
+ }
33
+
34
+ export async function withTemporaryFiles(files, ...callbacks) {
35
+ const temporaryDirectory = `${await fs.mkdtemp(
36
+ path.join(os.tmpdir(), "wtf-")
37
+ )}/`;
38
+
39
+ try {
40
+ const writes = Object.entries(files).map(async (entry) => {
41
+ const [filename, contents] = entry;
42
+ const filePath = path.join(temporaryDirectory, filename);
43
+
44
+ await ensureDirectoryExists(filePath);
45
+ await fs.writeFile(filePath, contents);
46
+ });
47
+
48
+ await Promise.all(writes);
49
+
50
+ for (const callback of callbacks) {
51
+ // eslint-disable-next-line no-await-in-loop, node/callback-return
52
+ await callback(temporaryDirectory, {
53
+ add: createAddFunction(temporaryDirectory),
54
+ remove: createRemoveFunction(temporaryDirectory),
55
+ });
56
+ }
57
+ } finally {
58
+ await fs.rm(temporaryDirectory, {
59
+ recursive: true,
60
+ });
61
+ }
62
+ }
@@ -0,0 +1,121 @@
1
+ import { once } from "node:events";
2
+
3
+ import { ModuleLoader } from "../src/module-loader.js";
4
+ import { Registry } from "../src/registry.js";
5
+
6
+ import { withTemporaryFiles } from "./lib/with-temporary-files.js";
7
+
8
+ describe("a module loader", () => {
9
+ it("finds a file and adds it to the registry", async () => {
10
+ const files = {
11
+ "hello.mjs": `
12
+ export function GET() {
13
+ return {
14
+ body: "hello"
15
+ };
16
+ }
17
+ `,
18
+ "a/b/c.mjs": `
19
+ export function GET() {
20
+ return {
21
+ body: "GET from a/b/c"
22
+ };
23
+ }
24
+ `,
25
+ };
26
+
27
+ await withTemporaryFiles(files, async (basePath) => {
28
+ const registry = new Registry();
29
+ const loader = new ModuleLoader(basePath, registry);
30
+
31
+ await loader.load();
32
+
33
+ expect(registry.exists("GET", "/hello")).toBe(true);
34
+ expect(registry.exists("POST", "/hello")).toBe(false);
35
+ expect(registry.exists("GET", "/goodbye")).toBe(false);
36
+ expect(registry.exists("GET", "/a/b/c")).toBe(true);
37
+ });
38
+ });
39
+
40
+ it("updates the registry when a file is added", async () => {
41
+ await withTemporaryFiles({}, async (basePath, { add }) => {
42
+ const registry = new Registry();
43
+ const loader = new ModuleLoader(basePath, registry);
44
+
45
+ await loader.load();
46
+ await loader.watch();
47
+
48
+ expect(registry.exists("GET", "/late/addition")).toBe(false);
49
+
50
+ add(
51
+ "late/addition.mjs",
52
+ 'export function GET() { return { body: "I\'m here now!" }; }'
53
+ );
54
+ await once(loader, "add");
55
+
56
+ expect(registry.exists("GET", "/late/addition")).toBe(true);
57
+
58
+ await loader.stopWatching();
59
+ });
60
+ });
61
+
62
+ it("updates the registry when a file is deleted", async () => {
63
+ await withTemporaryFiles(
64
+ {
65
+ "delete-me.mjs":
66
+ 'export function GET() { return { body: "Goodbye" }; }',
67
+ },
68
+ async (basePath, { remove }) => {
69
+ const registry = new Registry();
70
+ const loader = new ModuleLoader(basePath, registry);
71
+
72
+ await loader.load();
73
+ await loader.watch();
74
+
75
+ expect(registry.exists("GET", "/delete-me")).toBe(true);
76
+
77
+ remove("delete-me.mjs");
78
+ await once(loader, "remove");
79
+
80
+ expect(registry.exists("GET", "/delete-me")).toBe(false);
81
+
82
+ await loader.stopWatching();
83
+ }
84
+ );
85
+ });
86
+
87
+ // This should work but I can't figure out how to break the
88
+ // module cache when running through Jest (which uses the
89
+ // experimental module API).
90
+
91
+ // eslint-disable-next-line jest/no-disabled-tests
92
+ it.skip("updates the registry when a file is changed", async () => {
93
+ await withTemporaryFiles(
94
+ {
95
+ "change.mjs":
96
+ 'export function GET() { return { body: "before change" }; }',
97
+ },
98
+ async (basePath, { add }) => {
99
+ const registry = new Registry();
100
+ const loader = new ModuleLoader(basePath, registry);
101
+
102
+ await loader.watch();
103
+ add(
104
+ "change.mjs",
105
+ 'export function GET() { return { body: "after change" }; }'
106
+ );
107
+ await once(loader, "change");
108
+
109
+ const response = await registry.endpoint(
110
+ "GET",
111
+ "/change"
112
+ )({ path: "", reduce: () => undefined, store: {} });
113
+
114
+ expect(response.body).toBe("after change");
115
+ expect(registry.exists("GET", "/late/addition")).toBe(true);
116
+
117
+ await loader.stopWatching();
118
+ }
119
+ );
120
+ });
121
+ });
@@ -0,0 +1,105 @@
1
+ import { Registry } from "../src/registry.js";
2
+
3
+ describe("a scripted server", () => {
4
+ it("knows if a handler exists for a request method at a path", () => {
5
+ const registry = new Registry();
6
+
7
+ registry.add("/hello", {
8
+ async GET() {
9
+ await Promise.resolve("noop");
10
+
11
+ return { body: "hello" };
12
+ },
13
+ });
14
+
15
+ expect(registry.exists("GET", "/hello")).toBe(true);
16
+ expect(registry.exists("POST", "/hello")).toBe(false);
17
+ expect(registry.exists("GET", "/goodbye")).toBe(false);
18
+ });
19
+
20
+ it.todo("returns debug information if path does not exist");
21
+
22
+ it("returns a function matching the URL and request method", async () => {
23
+ const registry = new Registry();
24
+
25
+ registry.add("/a", {
26
+ async GET() {
27
+ await Promise.resolve("noop");
28
+
29
+ return { body: "GET a" };
30
+ },
31
+
32
+ async POST() {
33
+ await Promise.resolve("noop");
34
+
35
+ return { body: "POST a" };
36
+ },
37
+ });
38
+ registry.add("/b", {
39
+ async GET() {
40
+ await Promise.resolve("noop");
41
+
42
+ return { body: "GET b" };
43
+ },
44
+
45
+ async POST() {
46
+ await Promise.resolve("noop");
47
+
48
+ return { body: "POST b" };
49
+ },
50
+ });
51
+
52
+ const props = {
53
+ path: "",
54
+
55
+ reduce(foo) {
56
+ return foo;
57
+ },
58
+
59
+ store: {},
60
+ };
61
+ const getA = await registry.endpoint("GET", "/a")(props);
62
+ const getB = await registry.endpoint("GET", "/b")(props);
63
+ const postA = await registry.endpoint("POST", "/a")(props);
64
+ const postB = await registry.endpoint("POST", "/b")(props);
65
+
66
+ expect(getA.body).toBe("GET a");
67
+ expect(getB.body).toBe("GET b");
68
+ expect(postA.body).toBe("POST a");
69
+ expect(postB.body).toBe("POST b");
70
+ });
71
+
72
+ it("constructs a tree of the registered modules", () => {
73
+ const registry = new Registry();
74
+
75
+ registry.add("/nc", "North Carolina");
76
+ registry.add("/nc/charlotte/south-park", "South Park");
77
+ registry.add("/nc/charlotte", "Charlotte, NC");
78
+
79
+ const { nc } = registry.moduleTree.children;
80
+ const { charlotte } = nc.children;
81
+ const southPark = charlotte.children["south-park"];
82
+
83
+ expect(nc.module).toBe("North Carolina");
84
+ expect(charlotte.module).toBe("Charlotte, NC");
85
+ expect(southPark.module).toBe("South Park");
86
+ });
87
+
88
+ it("handles a dynamic path", () => {
89
+ const registry = new Registry();
90
+
91
+ registry.add("/[organization]/users/[username]/friends/[page]", {
92
+ GET({ path }) {
93
+ return {
94
+ body: `page ${path.page} of ${path.username}'s friends in ${path.organization}`,
95
+ };
96
+ },
97
+ });
98
+
99
+ expect(
100
+ registry.endpoint("GET", "/acme/users/alice/friends/2")({})
101
+ ).toStrictEqual({
102
+ body: "page 2 of alice's friends in acme",
103
+ });
104
+ });
105
+ });