@johnhenry/letterpress 0.0.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,259 @@
1
+ # Letterpress
2
+
3
+ [![npm version](https://badge.fury.io/js/%40johnhenry%2Fletterpress.svg)](https://www.npmjs.com/package/@johnhenry/letterpress)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ <img alt="Letterpress Logo" width="512" height="512" src="./logo.jpeg" style="width:512px;height:512px"/>
7
+
8
+ > Previously published as `leroute`, last unscoped version `0.0.1`. Now
9
+ > `@johnhenry/letterpress`, restarting at `0.0.0`.
10
+
11
+ Letterpress is a flexible and powerful routing library for handling HTTP requests and responses in JavaScript and TypeScript applications.
12
+
13
+ Letterpress works great with [leserve](https://www.npmjs.com/package/@johnhenry/leserve), a library for serving endpoints.
14
+
15
+ ## 🚀 Features
16
+
17
+ - Intuitive API using tagged template strings for route creation
18
+ - Support for simple and complex routing patterns
19
+ - Flexible response generation using template literals
20
+ - Middleware support for request and response processing
21
+ - Streaming response capabilities
22
+ - Full TypeScript support with included type definitions
23
+
24
+ ## 📦 Installation
25
+
26
+ ```bash
27
+ npm install @johnhenry/letterpress
28
+ ```
29
+
30
+ Or using yarn:
31
+
32
+ ```bash
33
+ yarn add @johnhenry/letterpress
34
+ ```
35
+
36
+ ## 🛠 Usage
37
+
38
+ ### Basic Example
39
+
40
+ ```javascript
41
+ import serve from "@johnhenry/leserve";
42
+ import { createRouter, createRoute } from "@johnhenry/letterpress";
43
+
44
+ // Create a router
45
+ const router = createRouter();
46
+
47
+ // Define a simple route
48
+ router.endpoint`GET /``
49
+ <!DOCTYPE html>
50
+ <html lang="en">
51
+ <head>
52
+ <meta charset="UTF-8">
53
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
54
+ <title>Welcome to Route</title>
55
+ </head>
56
+ <body>
57
+ <h1>Welcome to Route!</h1>
58
+ <p>The current time is: ${() => new Date().toISOString()}</p>
59
+ </body>
60
+ </html>
61
+ `;
62
+
63
+ // Define a route with parameters
64
+ router.endpoint`GET /user/:id``
65
+ <!DOCTYPE html>
66
+ <html lang="en">
67
+ <head>
68
+ <meta charset="UTF-8">
69
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
70
+ <title>User Profile</title>
71
+ </head>
72
+ <body>
73
+ <h1>User Profile</h1>
74
+ <p>User ID: ${(_, { params }) => params.id}</p>
75
+ </body>
76
+ </html>
77
+ `;
78
+
79
+ // Start the server
80
+ serve({ port: 8080 }, router);
81
+ ```
82
+
83
+ ### Advanced Usage
84
+
85
+ ```javascript
86
+ import serve from "@johnhenry/leserve";
87
+ import { createRouter, createRoute } from "@johnhenry/letterpress";
88
+
89
+ const router = createRouter();
90
+
91
+ // JSON API endpoint
92
+ router.endpoint`GET /api/data`(async (request) => {
93
+ const data = {
94
+ message: "Hello, World!",
95
+ timestamp: new Date().toISOString(),
96
+ };
97
+ return new Response(JSON.stringify(data), {
98
+ headers: { "Content-Type": "application/json" },
99
+ });
100
+ });
101
+
102
+ // Protected route with header matching
103
+ router.endpoint`GET /protected [Authorization: Bearer *]``
104
+ HTTP/1.1 200 OK
105
+ Content-Type: text/plain
106
+
107
+ This is a protected resource
108
+ `;
109
+
110
+ // Custom error handling
111
+ router.endpoint`GET /error`(() => {
112
+ throw new Error("Intentional error");
113
+ });
114
+
115
+ const errorHandler = (error, request) => {
116
+ console.error("Error:", error);
117
+ return new Response("An error occurred", { status: 500 });
118
+ };
119
+
120
+ serve({ port: 8080 }, router, { errorHandler });
121
+ ```
122
+
123
+ ## 📘 API Reference
124
+
125
+ ### `createRouter(options?: RouterInit): Router`
126
+
127
+ Creates a new router instance.
128
+
129
+ #### Options:
130
+
131
+ - `baseUrl`: Base URL for all routes (optional)
132
+ - `defaultHandler`: Default route handler (optional)
133
+ - `errorHandler`: Custom error handler function (optional)
134
+ - `cache`: Caching options (optional)
135
+
136
+ ### `createRoute(options?: RouteInit): Route`
137
+
138
+ Creates a new route handler.
139
+
140
+ #### Options:
141
+
142
+ - `headers`: Initial headers for the response (optional)
143
+ - `status`: HTTP status code (optional)
144
+ - `statusText`: HTTP status text (optional)
145
+ - `streaming`: Enable streaming response (optional)
146
+
147
+ ### `HTTPExpression`
148
+
149
+ A tagged-template function for matching HTTP requests against a method/path/header pattern.
150
+
151
+ ```javascript
152
+ import { HTTPExpression } from "@johnhenry/letterpress";
153
+
154
+ const expr = HTTPExpression`GET /users/:id`;
155
+ expr.test(request); // boolean — does this request match?
156
+ expr.exec(request); // matched params (plus method/headers), or null
157
+ ```
158
+
159
+ ### `createRequest`, `createResponse`
160
+
161
+ Tagged-template functions for building `Request`/`Response` objects from raw HTTP-message-shaped template literals. See [api.md](./api.md) for details.
162
+
163
+ ### `createFSRouter`
164
+
165
+ Builds a `Router` from a filesystem-based route directory.
166
+
167
+ ### `deconstruct`, `cook`
168
+
169
+ Lower-level utility functions used to parse and process tagged HTTP template literals. See [api.md](./api.md) for details.
170
+
171
+ > Note: `@johnhenry/letterpress` does not export a `serve` function. To actually run a server, pair it with [leserve](https://www.npmjs.com/package/@johnhenry/leserve) (or any server of your choice) as shown in the usage examples above.
172
+
173
+ ## 📜 Changelog
174
+
175
+ All notable changes to this project will be documented in this file.
176
+
177
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
178
+
179
+ This project will adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once it reaches 1.0.0.
180
+
181
+ ### [Unreleased]
182
+
183
+ #### Changed (breaking)
184
+
185
+ - **Renamed the package from `leroute` to `@johnhenry/letterpress`**, and
186
+ restarted the version at `0.0.0` (previously published as `leroute`, last
187
+ unscoped version `0.0.1`).
188
+ - **Renamed the exported identifiers to match**: `createLeRoute` →
189
+ `createRoute`, `createLeRouter` → `createRouter`, `LeRoute` → `Route`,
190
+ `LeRouteInit` → `RouteInit`, `LeRouteMiddleware` → `RouteMiddleware`,
191
+ `LeRouter` → `Router`, `LeRouterExtension` → `RouterExtension`,
192
+ `LeRouterInit` → `RouterInit`, `LeRouterMiddleware` → `RouterMiddleware`.
193
+ `create-leroute.mjs`/`create-lerouter.mjs` were also renamed to
194
+ `create-route.mjs`/`create-router.mjs` (and their test files
195
+ correspondingly), matching the new export names.
196
+ - **Raised `engines.node` to `>=26.0.0`**, matching the rest of the
197
+ `@johnhenry/*` family's floor.
198
+
199
+ #### Fixed
200
+
201
+ - 🐛 `test/index.mjs` imported `lerouter.mjs` twice and never imported `leroute.mjs`, silently dropping the `createRoute` test suite from `npm test`
202
+ - 🐛 `test/leroute.mjs` imported `createRoute` from `create-router.mjs` (which doesn't export it) instead of `create-route.mjs`, which would have thrown once the missing import above was fixed
203
+ - 🐛 `package.json` declared `"license": "ISC"` while the `LICENSE` file and README badge are MIT; changed to `"license": "MIT"` to match
204
+ - 📝 README and API docs referenced `serve` and `tagRequest` as exports of `leroute`; neither is exported — `serve` comes from the separate `leserve` package, and `tagRequest` doesn't exist. Docs corrected to list the real exports
205
+ - 📝 API docs showed `HTTPExpression(...)` as a constructor with `.method`/`.path`/`.version` properties; corrected to show it as a tagged-template function returning `{ test(request), exec(request) }`
206
+ - 🔒 **Security**: `router.endpoint` reused the per-request context object (which carries the incoming request's `Headers`) as the response-init object passed to `createRoute`, so every incoming request header — `Authorization`, `Cookie`, arbitrary custom headers — was echoed back on every response
207
+ - 🐛 `createRouter`'s dispatch loop called `handler(request, ctx)` without `await`, so an async handler that threw was never caught by the configured `errorHandler`/`defaultHandler` and instead crashed as an unhandled rejection
208
+ - 🐛 `createRoute` threw `Cannot read properties of null/undefined (reading 'toString')` on a `${null}`/`${undefined}` substitution instead of rendering nothing (ordinary template-literal semantics), even though a substitution *function* returning `undefined` was already treated as "no output"
209
+ - 🐛 A substitution *function* returning a `ReadableStream`/`Blob`/`ArrayBuffer`/`Uint8Array` fell through to `.toString()`, producing the literal string `"[object ReadableStream]"` instead of streaming the actual content (direct substitutions of these types already worked correctly)
210
+ - 🐛 `utility/dynamic-run.mjs` unconditionally attempted to `fs.unlink()` its temp file even when `fs.writeFile()` itself had failed, masking the real write error behind a misleading `ENOENT` — which `fs-router.mjs` then silently treated as "route not found" instead of surfacing a genuine permissions/disk-full failure. A cleanup (`unlink`) failure *after* a successful import also discarded the successful result
211
+ - 🐛 `fs-router.mjs`'s `index.html` handling wrote its synthesized temp module into leroute's own installed package directory instead of the matched route's directory — breaking the feature entirely in any deployment where the package install directory is read-only (containers, CI-built images)
212
+
213
+ #### Added
214
+
215
+ - `"types"` field in `package.json` pointing to the existing `types.d.ts`
216
+ - A real `description` and `keywords` in `package.json` (previously empty)
217
+
218
+ #### Removed
219
+
220
+ - Deleted `utility/cd.mjs`, an unused debug scratch file with no imports and no tests
221
+
222
+ ### [0.0.0] - 2024-08-26
223
+
224
+ #### Added
225
+
226
+ - 🎉 Initial release of Route
227
+ - 🛠 Core routing functionality
228
+ - 🛠 HTTP request and response handling
229
+ - 🛠 TypeScript definitions
230
+ - 🛠 Support for route parameters and query strings
231
+
232
+ ## 🤝 Contributing
233
+
234
+ We welcome contributions to Route! Here's how you can help:
235
+
236
+ 1. Fork the repository
237
+ 2. Create a new branch: `git checkout -b feature/your-feature-name`
238
+ 3. Make your changes and commit them: `git commit -m 'Add some feature'`
239
+ 4. Push to the branch: `git push origin feature/your-feature-name`
240
+ 5. Submit a pull request
241
+
242
+ Please make sure to update tests as appropriate and adhere to the existing coding style.
243
+
244
+ ## 📄 License
245
+
246
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
247
+
248
+ ## 🙏 Acknowledgements
249
+
250
+ - Thanks to all contributors who have helped shape Route
251
+ - Inspired by modern web development practices and the need for flexible routing solutions
252
+
253
+ ## 📬 Contact
254
+
255
+ For questions, suggestions, or issues, please open an issue on the GitHub repository or contact the maintainers directly.
256
+
257
+ ---
258
+
259
+ Happy routing with Route! 🚀
@@ -0,0 +1,68 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert";
3
+ import { promises as fs } from "fs";
4
+ import path from "path";
5
+ import os from "os";
6
+ import dynamicRun from "../utility/dynamic-run.mjs";
7
+
8
+ test("dynamicRun - surfaces the original error when writing the temp file fails, instead of masking it with a cleanup ENOENT", async () => {
9
+ // Regression test: dynamicRun's catch block used to call
10
+ // `await fs.unlink(tempFilePath)` unconditionally, even when
11
+ // `fs.writeFile` itself was the thing that failed (so the temp file was
12
+ // never created). That unlink attempt threw its own ENOENT, which
13
+ // replaced/masked the real underlying error (e.g. a permissions or
14
+ // disk-full failure) - and fs-router.mjs specifically treats ENOENT as
15
+ // "route not found", silently turning a real write failure into a 404.
16
+ const originalWriteFile = fs.writeFile;
17
+ const originalUnlink = fs.unlink;
18
+ let unlinkCalled = false;
19
+
20
+ fs.writeFile = async () => {
21
+ const error = new Error("simulated EACCES: permission denied");
22
+ error.code = "EACCES";
23
+ throw error;
24
+ };
25
+ fs.unlink = async (...args) => {
26
+ unlinkCalled = true;
27
+ return originalUnlink(...args);
28
+ };
29
+
30
+ try {
31
+ await assert.rejects(
32
+ () => dynamicRun("export default 1;", os.tmpdir()),
33
+ (error) => {
34
+ assert.equal(error.code, "EACCES");
35
+ assert.match(error.message, /simulated EACCES/);
36
+ return true;
37
+ }
38
+ );
39
+ assert.equal(
40
+ unlinkCalled,
41
+ false,
42
+ "unlink should not be attempted for a file that was never written"
43
+ );
44
+ } finally {
45
+ fs.writeFile = originalWriteFile;
46
+ fs.unlink = originalUnlink;
47
+ }
48
+ });
49
+
50
+ test("dynamicRun - a cleanup (unlink) failure after a successful import does not discard the imported module", async () => {
51
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "letterpress-dynrun-"));
52
+ const originalUnlink = fs.unlink;
53
+ fs.unlink = async () => {
54
+ const error = new Error("simulated cleanup failure");
55
+ throw error;
56
+ };
57
+
58
+ try {
59
+ const result = await dynamicRun(
60
+ "export default 42;",
61
+ tmpDir
62
+ );
63
+ assert.equal(result.default, 42);
64
+ } finally {
65
+ fs.unlink = originalUnlink;
66
+ await fs.rm(tmpDir, { recursive: true, force: true });
67
+ }
68
+ });
@@ -0,0 +1,83 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert";
3
+ import fs from "fs/promises";
4
+ import path from "path";
5
+ import os from "os";
6
+ import { createFSRouter } from "../fs-router.mjs";
7
+
8
+ test("fs-router - writes the synthesized index.html temp module into the matched route directory", async () => {
9
+ // Regression test: fs-router used to pass letterpress's own package directory
10
+ // (from theresWaldo(import.meta.url)) to dynamicRun instead of the
11
+ // matched route's directory. That meant every index.html request required
12
+ // the *installed package's own directory* (e.g. node_modules/@johnhenry/letterpress) to
13
+ // be writable - unrelated to, and often less permissive than, the app's
14
+ // own route directory - and had nothing to do with where the index.html
15
+ // actually lived.
16
+ const tmpRoot = await fs.mkdtemp(
17
+ path.join(os.tmpdir(), "letterpress-fsrouter-")
18
+ );
19
+ const routeDir = path.join(tmpRoot, "routes");
20
+ await fs.mkdir(routeDir, { recursive: true });
21
+ await fs.writeFile(
22
+ path.join(routeDir, "index.html"),
23
+ "<p>hello ${() => 1 + 1}</p>"
24
+ );
25
+
26
+ const writtenPaths = [];
27
+ const originalWriteFile = fs.writeFile;
28
+ fs.writeFile = (filePath, ...rest) => {
29
+ writtenPaths.push(filePath);
30
+ return originalWriteFile(filePath, ...rest);
31
+ };
32
+
33
+ let response;
34
+ try {
35
+ const router = createFSRouter(routeDir);
36
+ const request = new Request("http://localhost/", {
37
+ headers: { host: "localhost" },
38
+ });
39
+ response = await router(request);
40
+ } finally {
41
+ fs.writeFile = originalWriteFile;
42
+ }
43
+
44
+ try {
45
+ assert.equal(response.status, 200);
46
+ assert.match(await response.text(), /hello 2/);
47
+ assert.equal(writtenPaths.length, 1);
48
+ assert.equal(
49
+ path.dirname(writtenPaths[0]),
50
+ routeDir,
51
+ "temp module should be written into the matched route directory, not the letterpress package directory"
52
+ );
53
+ } finally {
54
+ await fs.rm(tmpRoot, { recursive: true, force: true });
55
+ }
56
+ });
57
+
58
+ test("fs-router - cleans up its temp module after serving an index.html route", async () => {
59
+ const tmpRoot = await fs.mkdtemp(
60
+ path.join(os.tmpdir(), "letterpress-fsrouter-")
61
+ );
62
+ const routeDir = path.join(tmpRoot, "routes");
63
+ await fs.mkdir(routeDir, { recursive: true });
64
+ await fs.writeFile(path.join(routeDir, "index.html"), "<p>hi</p>");
65
+
66
+ try {
67
+ const router = createFSRouter(routeDir);
68
+ const request = new Request("http://localhost/", {
69
+ headers: { host: "localhost" },
70
+ });
71
+ const response = await router(request);
72
+ assert.equal(response.status, 200);
73
+
74
+ const remaining = await fs.readdir(routeDir);
75
+ assert.deepEqual(
76
+ remaining.sort(),
77
+ ["index.html"],
78
+ "no orphaned temp-*.mjs file should remain after the request"
79
+ );
80
+ } finally {
81
+ await fs.rm(tmpRoot, { recursive: true, force: true });
82
+ }
83
+ });