@johnhenry/letterpress 0.0.3 → 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 CHANGED
@@ -6,6 +6,62 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
6
6
 
7
7
  This project will adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once it reaches 1.0.0.
8
8
 
9
+ ## [0.1.0] - 2026-09-28
10
+
11
+ ### Added
12
+
13
+ - ✨ **`createRewriter`**: `leroute` (the package letterpress was renamed
14
+ from -- see the README's provenance note) had a `createRewriter` export
15
+ (`leroute/rewrite`) for request/response rewriting: rewriting an inbound
16
+ request's target path/headers before it reaches a handler, and rewriting
17
+ a handler's response headers/status on the way back out. This never got
18
+ carried over during the leroute -> `@johnhenry/letterpress` rename --
19
+ it simply wasn't in the initial `@johnhenry/letterpress` source, a gap
20
+ in that migration rather than an intentional removal. It was discovered
21
+ while porting a downstream consumer, `prism` (a live HTTP request
22
+ inspector/proxy), whose proxy mode depends on it for rewriting proxied
23
+ requests/responses. Ported unchanged in behavior as `rewrite.mjs`
24
+ (`createRewriter`, plus `rewriteRequest`/`rewriteResponse`/`addRule`/
25
+ `removeRule`/`getRules`/`middleware`), exposed via a dedicated
26
+ `@johnhenry/letterpress/rewrite` subpath -- mirroring how
27
+ `createFSRouter` is isolated on `@johnhenry/letterpress/fs` rather than
28
+ the main barrel -- with its own `rewrite.d.ts` types wired into
29
+ `package.json`'s `exports` map and `tsconfig.json`. Tests ported from
30
+ leroute's `test/rewrite.mjs` into `test/rewrite.mjs`, plus new
31
+ `./rewrite` subpath-export regression tests in `test/exports.mjs` and
32
+ `test/types/fixture.ts` coverage, following the same pattern the `./fs`
33
+ subpath already established.
34
+
35
+ - ✨ **`router.mount(prefix, subHandler)`**: another `leroute` capability
36
+ (`create-lerouter.mjs`'s `router.mount`) dropped during the same rename,
37
+ found via the same `prism` port -- prism's own top-level router uses it
38
+ to mount its dashboard sub-router under `/inspect`. Delegates every
39
+ request under `prefix` to `subHandler` with the prefix stripped from the
40
+ forwarded request's path; `subHandler` may be a plain `(Request) =>
41
+ Response` function or an object exposing one as `.fetch`, so a `Router`
42
+ can mount another `Router`. Ported onto the main barrel (`create-router.mjs`,
43
+ right alongside `router.endpoint`, not an isolated subpath like `rewrite`
44
+ -- unlike `rewrite`/`fs`, `mount` is core routing behavior every router
45
+ needs, not an optional extra), dropping only the old implementation's
46
+ now-obsolete `middleware` and `request.raw` preservation (the current
47
+ `createRouter` doesn't have either concept). New `Mountable` type in
48
+ `types.d.ts`; seven new tests in `test/router.mjs` covering delegation,
49
+ the bare-prefix-with-no-trailing-segment case, 404 fallthrough for
50
+ non-matching paths, the `.fetch`-object form, query-string/method
51
+ preservation on the forwarded request, and ctx forwarding (below).
52
+
53
+ - ✨ **The router's own dispatch now accepts an optional second `ctx`
54
+ argument** (`router(request, ctx?)`), merged underneath `init` and the
55
+ matched route's own params so a route's params can't be shadowed by it,
56
+ and forwarded to `defaultHandler` too. This was also present in `leroute`
57
+ (`router(request, ctx = {})`) and dropped during the rename; brought back
58
+ specifically so `mount()` can hand a mounted sub-router its `mountPrefix`
59
+ (used by `prism` to reconstruct the full request path for display in its
60
+ captured-request feed, purely cosmetic but a real fidelity gap otherwise)
61
+ -- caught by testing `mount()` end-to-end against `prism` itself, not by
62
+ a direct comparison against the old source this time. Existing single-arg
63
+ `router(request)` callers are unaffected (`ctx` defaults to `{}`).
64
+
9
65
  ## [0.0.3] - 2026-09-26
10
66
 
11
67
  ### Fixed
package/README.md CHANGED
@@ -148,6 +148,23 @@ Creates a new router instance.
148
148
  - `errorHandler`: Custom error handler function (optional)
149
149
  - `cache`: Caching options (optional)
150
150
 
151
+ ### `router.mount(prefix: string, subHandler: Mountable): Router`
152
+
153
+ Delegates every request under `prefix` to `subHandler`, with the prefix
154
+ stripped from the forwarded request's path — e.g. `router.mount("/api",
155
+ apiRouter)` sends a request for `/api/users` to `apiRouter` as `/users`.
156
+ `subHandler` may be a plain `(Request) => Response` function or an object
157
+ exposing one as `.fetch` — a `Router` satisfies both, so one router can
158
+ mount another:
159
+
160
+ ```javascript
161
+ const api = createRouter();
162
+ api.endpoint`GET /users``[]`;
163
+
164
+ const router = createRouter();
165
+ router.mount("/api", api); // GET /api/users -> api's GET /users
166
+ ```
167
+
151
168
  ### `createRoute(options?: RouteInit): Route`
152
169
 
153
170
  Creates a new route handler.
@@ -191,6 +208,47 @@ import { createFSRouter } from "@johnhenry/letterpress/fs";
191
208
  > else (`createRouter`, `createRoute`, `createRequest`, `createResponse`,
192
209
  > `HTTPExpression`) is pure and safe to bundle for the browser.
193
210
 
211
+ ### `createRewriter`
212
+
213
+ Builds a request/response rewriter for target-URL/path rewriting -- e.g.
214
+ rewriting an inbound path before it reaches a route, or rewriting a
215
+ proxied response's headers/status on the way back out. Useful for proxy
216
+ middleware (rewrite the target URL/path of a proxied request, adjust
217
+ headers, force a status code) as well as plain routing (redirect old
218
+ paths to new ones).
219
+
220
+ ```javascript
221
+ import { createRewriter } from "@johnhenry/letterpress/rewrite";
222
+
223
+ const rewriter = createRewriter([
224
+ {
225
+ match: { path: /^\/api\/v1\/(.*)/ },
226
+ action: { rewritePath: "/api/v2/$1" },
227
+ },
228
+ ]);
229
+
230
+ // Wrap a handler: the handler receives the rewritten request, and its
231
+ // response is run back through the rewriter before being returned.
232
+ const handler = rewriter.middleware(async (request) => {
233
+ const url = new URL(request.url);
234
+ return new Response(`routed to ${url.pathname}`);
235
+ });
236
+
237
+ // Or call rewriteRequest/rewriteResponse directly, and manage rules with
238
+ // addRule/removeRule/getRules.
239
+ rewriter.addRule({ match: { path: "/teapot" }, action: { setStatus: 418 } });
240
+ ```
241
+
242
+ > `createRewriter` is imported from the `@johnhenry/letterpress/rewrite`
243
+ > subpath, not the main `@johnhenry/letterpress` barrel, mirroring how
244
+ > `createFSRouter` is isolated on `@johnhenry/letterpress/fs` above.
245
+ >
246
+ > This existed in `leroute` (the package letterpress was renamed from --
247
+ > see the provenance note near the top of this README) but was dropped
248
+ > during that rename. It's ported here, discovered missing while porting a
249
+ > downstream consumer (`prism`, a request inspector/proxy) that relies on
250
+ > it for proxy-mode rewriting -- see CHANGELOG.md.
251
+
194
252
  ### `deconstruct`, `cook`
195
253
 
196
254
  Lower-level utility functions used to parse and process tagged HTTP template literals. See [api.md](./api.md) for details.
@@ -241,6 +299,13 @@ is designed to pair with a sibling package that does.
241
299
  `Route`-shaped function works too; `leserve` is the tested, documented
242
300
  pairing, not a hard dependency (it's a `devDependency` here, used only in
243
301
  the demo scripts).
302
+ - **[`@johnhenry/prism`](https://github.com/johnhenry/prism)** -- a live
303
+ HTTP request inspector/proxy that uses `createRouter()` as its top-level
304
+ router. Porting it surfaced two real gaps left over from letterpress's
305
+ own rename from `leroute`: `router.mount()` (with the `ctx`-forwarding
306
+ its `mountPrefix` depends on) and `createRewriter`
307
+ (`@johnhenry/letterpress/rewrite`) -- both closed as part of that port,
308
+ see CHANGELOG.md.
244
309
 
245
310
  ## 🤝 Contributing
246
311
 
package/create-router.mjs CHANGED
@@ -11,7 +11,12 @@ export const createRouter = (initial = {}) => {
11
11
  ...init
12
12
  } = initial;
13
13
 
14
- const router = async (request) => {
14
+ // `ctx` is an optional caller-supplied context object (e.g. `.mount()`
15
+ // uses it to pass `mountPrefix` down to a mounted sub-router) merged
16
+ // underneath the router's own `init` and the matched route's own params --
17
+ // so a route's own params can't be shadowed by a mount's ctx, but a
18
+ // handler can still read whatever the caller passed in.
19
+ const router = async (request, ctx = {}) => {
15
20
  try {
16
21
  for (const [matcher, handler] of routes) {
17
22
  const match = matcher(request);
@@ -20,10 +25,10 @@ export const createRouter = (initial = {}) => {
20
25
  // directly) is required so that a handler that throws inside an
21
26
  // async function (i.e. rejects its returned promise) is still
22
27
  // caught below and routed to errorHandler.
23
- return await handler(request, { ...init, ...match });
28
+ return await handler(request, { ...ctx, ...init, ...match });
24
29
  }
25
30
  }
26
- return await defaultHandler(request);
31
+ return await defaultHandler(request, ctx);
27
32
  } catch (error) {
28
33
  return errorHandler(error, request);
29
34
  }
@@ -57,5 +62,47 @@ export const createRouter = (initial = {}) => {
57
62
  return router;
58
63
  };
59
64
  };
65
+
66
+ // Delegate every request under `prefix` to `subHandler`, with the prefix
67
+ // stripped from the forwarded request's path -- e.g. `router.mount("/api",
68
+ // apiRouter)` sends a request for `/api/users` to `apiRouter` as `/users`.
69
+ // `subHandler` may be a plain `(Request) => Response` function or an
70
+ // object exposing one as `.fetch` (matching how `Router` itself works, so
71
+ // one router can mount another).
72
+ router.mount = (prefix, subHandler) => {
73
+ const normalizedPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
74
+
75
+ const matcher = (request) => {
76
+ const url = new URL(request.url);
77
+ if (
78
+ url.pathname === normalizedPrefix ||
79
+ url.pathname.startsWith(normalizedPrefix + "/")
80
+ ) {
81
+ return { params: {}, method: request.method, headers: {} };
82
+ }
83
+ return null;
84
+ };
85
+
86
+ const handler = (request) => {
87
+ const url = new URL(request.url);
88
+ const newPath = url.pathname.slice(normalizedPrefix.length) || "/";
89
+ const newUrl = new URL(newPath + url.search + url.hash, url.origin);
90
+
91
+ const hasBody = request.method !== "GET" && request.method !== "HEAD";
92
+ const newRequest = new Request(newUrl.toString(), {
93
+ method: request.method,
94
+ headers: request.headers,
95
+ body: hasBody ? request.body : undefined,
96
+ duplex: hasBody ? "half" : undefined,
97
+ });
98
+
99
+ const sub = typeof subHandler === "function" ? subHandler : subHandler.fetch;
100
+ return sub(newRequest, { mountPrefix: normalizedPrefix });
101
+ };
102
+
103
+ routes.push([matcher, handler]);
104
+ return router;
105
+ };
106
+
60
107
  return router;
61
108
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/letterpress",
3
- "version": "0.0.3",
3
+ "version": "0.1.0",
4
4
  "description": "A tagged-template-string HTTP routing library for defining and matching request handlers with plain JavaScript syntax.",
5
5
  "main": "index.mjs",
6
6
  "types": "types.d.ts",
@@ -13,6 +13,10 @@
13
13
  "types": "./fs.d.ts",
14
14
  "default": "./fs-router.mjs"
15
15
  },
16
+ "./rewrite": {
17
+ "types": "./rewrite.d.ts",
18
+ "default": "./rewrite.mjs"
19
+ },
16
20
  "./package.json": "./package.json",
17
21
  "./*": "./*"
18
22
  },
@@ -58,4 +62,4 @@
58
62
  "dependencies": {
59
63
  "theres-waldo": "^0.0.0"
60
64
  }
61
- }
65
+ }
package/rewrite.d.ts ADDED
@@ -0,0 +1,61 @@
1
+ // Type declarations for the @johnhenry/letterpress/rewrite subpath.
2
+ //
3
+ // createRewriter is a request/response rewriter for target-URL/path
4
+ // rewriting (e.g. rewriting an inbound path before it reaches a route, or
5
+ // rewriting a proxied response's headers/status on the way back out). It
6
+ // existed in `leroute` (the package letterpress was renamed from) but was
7
+ // dropped during that rename; it's kept on its own subpath here (rather
8
+ // than the main barrel) to mirror how `./fs` is isolated -- see
9
+ // rewrite.mjs's top-of-file comment and CHANGELOG.md.
10
+
11
+ export type RewriteMatch = {
12
+ path?: string | RegExp;
13
+ method?: string;
14
+ };
15
+
16
+ export type RewriteAction = {
17
+ setHeader?: Record<string, string>;
18
+ removeHeader?: string[];
19
+ // Replacement string for the matched path; supports `$1`-style regex
20
+ // capture group references when `match.path` is a RegExp.
21
+ rewritePath?: string;
22
+ // Response only.
23
+ setStatus?: number;
24
+ };
25
+
26
+ export type RewriteRule = {
27
+ match?: RewriteMatch;
28
+ action?: RewriteAction;
29
+ };
30
+
31
+ // The shape returned by getRules(): a rule with its index in the rule
32
+ // list, and any RegExp `match.path` normalized back to its `.source`
33
+ // string.
34
+ export type RewriteRuleSnapshot = {
35
+ index: number;
36
+ match: {
37
+ path?: string;
38
+ method?: string;
39
+ };
40
+ action?: RewriteAction;
41
+ };
42
+
43
+ // Wraps a Route-shaped handler ((request, context) => Response) with
44
+ // request/response rewriting.
45
+ export type RewriterMiddleware = (
46
+ next: (request: Request, context?: Record<string, any>) => Response | Promise<Response>
47
+ ) => (request: Request, context?: Record<string, any>) => Promise<Response>;
48
+
49
+ export type Rewriter = {
50
+ middleware: RewriterMiddleware;
51
+ rewriteRequest: (request: Request) => Request;
52
+ rewriteResponse: (response: Response, request?: Request) => Response;
53
+ addRule: (rule: RewriteRule) => void;
54
+ removeRule: (index: number) => void;
55
+ getRules: () => RewriteRuleSnapshot[];
56
+ };
57
+
58
+ export type CreateRewriter = (initialRules?: RewriteRule[]) => Rewriter;
59
+
60
+ export declare const createRewriter: CreateRewriter;
61
+ export default createRewriter;
package/rewrite.mjs ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * @file rewrite.mjs
3
+ * @description Create a request/response rewriter for target-URL/path
4
+ * rewriting -- e.g. rewriting an inbound path before it reaches a route, or
5
+ * rewriting a proxied response's headers/status on the way back out.
6
+ *
7
+ * This existed in `leroute` (the package letterpress was renamed from --
8
+ * see the README's provenance note) as `createRewriter`, but was dropped
9
+ * during the leroute -> @johnhenry/letterpress rename. It's ported here
10
+ * unchanged in behavior, discovered missing while porting a downstream
11
+ * consumer (`prism`, a request inspector/proxy) that relies on it for
12
+ * proxy-mode rewriting.
13
+ *
14
+ * Rules format:
15
+ * {
16
+ * match: { path?: string|RegExp, method?: string },
17
+ * action: {
18
+ * setHeader?: { [name]: value },
19
+ * removeHeader?: string[],
20
+ * rewritePath?: string, // replacement string (supports $1 from regex)
21
+ * setStatus?: number, // response only
22
+ * }
23
+ * }
24
+ *
25
+ * @param {Array} [initialRules=[]]
26
+ */
27
+ export const createRewriter = (initialRules = []) => {
28
+ const rules = [...initialRules];
29
+
30
+ const matches = (rule, request) => {
31
+ if (!rule.match) return true;
32
+ const url = new URL(request.url);
33
+ if (rule.match.method && rule.match.method.toUpperCase() !== request.method) {
34
+ return false;
35
+ }
36
+ if (rule.match.path) {
37
+ const pattern = rule.match.path;
38
+ if (pattern instanceof RegExp) {
39
+ return pattern.test(url.pathname);
40
+ }
41
+ // String match — treat as prefix or exact
42
+ if (!url.pathname.startsWith(pattern)) return false;
43
+ }
44
+ return true;
45
+ };
46
+
47
+ const rewriteRequest = (request) => {
48
+ let url = new URL(request.url);
49
+ let headers = new Headers(request.headers);
50
+ let pathChanged = false;
51
+
52
+ for (const rule of rules) {
53
+ if (!matches(rule, request)) continue;
54
+ const action = rule.action || {};
55
+
56
+ if (action.setHeader) {
57
+ for (const [name, value] of Object.entries(action.setHeader)) {
58
+ headers.set(name, value);
59
+ }
60
+ }
61
+ if (action.removeHeader) {
62
+ for (const name of action.removeHeader) {
63
+ headers.delete(name);
64
+ }
65
+ }
66
+ if (action.rewritePath) {
67
+ if (rule.match?.path instanceof RegExp) {
68
+ url.pathname = url.pathname.replace(rule.match.path, action.rewritePath);
69
+ } else {
70
+ url.pathname = action.rewritePath;
71
+ }
72
+ pathChanged = true;
73
+ }
74
+ }
75
+
76
+ if (pathChanged || !headersEqual(headers, request.headers)) {
77
+ return new Request(url, {
78
+ method: request.method,
79
+ headers,
80
+ body: request.body,
81
+ duplex: "half",
82
+ });
83
+ }
84
+ return request;
85
+ };
86
+
87
+ const rewriteResponse = (response, request) => {
88
+ let headers = new Headers(response.headers);
89
+ let status = response.status;
90
+ let statusText = response.statusText;
91
+ let changed = false;
92
+
93
+ for (const rule of rules) {
94
+ // When request is provided, filter rules through matches()
95
+ // Rules with no match clause still apply globally
96
+ if (request && !matches(rule, request)) continue;
97
+
98
+ const action = rule.action || {};
99
+ if (action.setHeader) {
100
+ for (const [name, value] of Object.entries(action.setHeader)) {
101
+ headers.set(name, value);
102
+ }
103
+ changed = true;
104
+ }
105
+ if (action.removeHeader) {
106
+ for (const name of action.removeHeader) {
107
+ headers.delete(name);
108
+ }
109
+ changed = true;
110
+ }
111
+ if (action.setStatus) {
112
+ status = action.setStatus;
113
+ changed = true;
114
+ }
115
+ }
116
+
117
+ if (changed) {
118
+ return new Response(response.body, { status, statusText, headers });
119
+ }
120
+ return response;
121
+ };
122
+
123
+ /**
124
+ * Wraps a Route-shaped handler ((request, context) => Response) with
125
+ * request/response rewriting: the handler receives the rewritten
126
+ * request, and the handler's response is then run through
127
+ * rewriteResponse before being returned.
128
+ * @param {Function} next - The next handler: (request, context) => Response
129
+ * @returns {Function} Wrapped handler: (request, context) => Response
130
+ */
131
+ const middleware = (next) => async (request, context) => {
132
+ const rewrittenReq = rewriteRequest(request);
133
+ const response = await next(rewrittenReq, context);
134
+ return rewriteResponse(response, request);
135
+ };
136
+
137
+ const addRule = (rule) => {
138
+ // Convert path string to regex if it looks like one
139
+ if (rule.match?.path && typeof rule.match.path === "string" && rule.match.path.startsWith("/") && rule.match.path.endsWith("/")) {
140
+ const inner = rule.match.path.slice(1, -1);
141
+ try {
142
+ rule.match.path = new RegExp(inner);
143
+ } catch {
144
+ throw new Error(`Invalid regex pattern: ${rule.match.path}`);
145
+ }
146
+ }
147
+ rules.push(rule);
148
+ };
149
+
150
+ const removeRule = (index) => {
151
+ if (index >= 0 && index < rules.length) {
152
+ rules.splice(index, 1);
153
+ }
154
+ };
155
+
156
+ const getRules = () =>
157
+ rules.map((r, i) => ({
158
+ index: i,
159
+ match: {
160
+ path: r.match?.path instanceof RegExp ? r.match.path.source : r.match?.path,
161
+ method: r.match?.method,
162
+ },
163
+ action: r.action,
164
+ }));
165
+
166
+ return { middleware, rewriteRequest, rewriteResponse, addRule, removeRule, getRules };
167
+ };
168
+
169
+ const headersEqual = (a, b) => {
170
+ const aEntries = [...a].sort(([k1], [k2]) => k1.localeCompare(k2));
171
+ const bEntries = [...b].sort(([k1], [k2]) => k1.localeCompare(k2));
172
+ if (aEntries.length !== bEntries.length) return false;
173
+ return aEntries.every(([k, v], i) => bEntries[i][0] === k && bEntries[i][1] === v);
174
+ };
175
+
176
+ export default createRewriter;
package/test/exports.mjs CHANGED
@@ -67,3 +67,31 @@ test("@johnhenry/letterpress (self-reference) does not carry createFSRouter", as
67
67
  const barrel = await import("@johnhenry/letterpress");
68
68
  assert.strictEqual("createFSRouter" in barrel, false);
69
69
  });
70
+
71
+ // Regression tests closing the leroute -> @johnhenry/letterpress migration
72
+ // gap: `createRewriter` existed in leroute (see rewrite.mjs's top-of-file
73
+ // comment and CHANGELOG.md) but was never carried over during the rename.
74
+ // It's exposed the same way createFSRouter is -- a dedicated subpath, not
75
+ // the main barrel -- for consistency with that existing precedent.
76
+
77
+ test("createRewriter is exported from rewrite.mjs directly", async () => {
78
+ const rewriteModule = await import("../rewrite.mjs");
79
+ assert.strictEqual(typeof rewriteModule.createRewriter, "function");
80
+ });
81
+
82
+ test("package.json exposes a ./rewrite subpath export for createRewriter", async () => {
83
+ const pkg = await import("../package.json", { with: { type: "json" } });
84
+ const exportsMap = pkg.default.exports;
85
+ assert.strictEqual(exportsMap["./rewrite"].default, "./rewrite.mjs");
86
+ assert.strictEqual(exportsMap["./rewrite"].types, "./rewrite.d.ts");
87
+ });
88
+
89
+ test("@johnhenry/letterpress/rewrite resolves createRewriter via self-reference", async () => {
90
+ const rewriteSubpath = await import("@johnhenry/letterpress/rewrite");
91
+ assert.strictEqual(typeof rewriteSubpath.createRewriter, "function");
92
+ });
93
+
94
+ test("@johnhenry/letterpress (self-reference) does not carry createRewriter", async () => {
95
+ const barrel = await import("@johnhenry/letterpress");
96
+ assert.strictEqual("createRewriter" in barrel, false);
97
+ });
package/test/index.mjs CHANGED
@@ -3,6 +3,7 @@ import "./http-expression.mjs";
3
3
  import "./route.mjs";
4
4
  import "./req-res.mjs";
5
5
  import "./fs-router.mjs";
6
+ import "./rewrite.mjs";
6
7
  import "./dynamic-run.mjs";
7
8
  import "./exports.mjs";
8
9
  import "./types.test.mjs";
@@ -0,0 +1,202 @@
1
+ import { test, describe } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createRewriter } from "../rewrite.mjs";
4
+
5
+ // Ported from leroute's test/rewrite.mjs. createRewriter existed in
6
+ // leroute (the package letterpress was renamed from) but was dropped
7
+ // during the leroute -> @johnhenry/letterpress rename; this closes that
8
+ // migration gap -- see rewrite.mjs's top-of-file comment and
9
+ // CHANGELOG.md. Test cases are unchanged from the leroute original.
10
+
11
+ describe("Rewrite", () => {
12
+ test("should rewrite request path with regex capture groups", () => {
13
+ const rewriter = createRewriter([
14
+ {
15
+ match: { path: /^\/api\/v1\/(.*)/ },
16
+ action: { rewritePath: "/api/v2/$1" },
17
+ },
18
+ ]);
19
+
20
+ const req = new Request("http://localhost/api/v1/users");
21
+ const rewritten = rewriter.rewriteRequest(req);
22
+ const url = new URL(rewritten.url);
23
+ assert.equal(url.pathname, "/api/v2/users");
24
+ });
25
+
26
+ test("should set and remove request headers", () => {
27
+ const rewriter = createRewriter([
28
+ {
29
+ action: {
30
+ setHeader: { "x-custom": "hello" },
31
+ removeHeader: ["x-remove-me"],
32
+ },
33
+ },
34
+ ]);
35
+
36
+ const req = new Request("http://localhost/test", {
37
+ headers: { "x-remove-me": "gone" },
38
+ });
39
+ const rewritten = rewriter.rewriteRequest(req);
40
+ assert.equal(rewritten.headers.get("x-custom"), "hello");
41
+ assert.equal(rewritten.headers.get("x-remove-me"), null);
42
+ });
43
+
44
+ test("should set and remove response headers", () => {
45
+ const rewriter = createRewriter([
46
+ {
47
+ action: {
48
+ setHeader: { "x-powered-by": "letterpress" },
49
+ removeHeader: ["server"],
50
+ },
51
+ },
52
+ ]);
53
+
54
+ const res = new Response("ok", {
55
+ headers: { server: "old" },
56
+ });
57
+ const rewritten = rewriter.rewriteResponse(res);
58
+ assert.equal(rewritten.headers.get("x-powered-by"), "letterpress");
59
+ assert.equal(rewritten.headers.get("server"), null);
60
+ });
61
+
62
+ test("should set response status", () => {
63
+ const rewriter = createRewriter([
64
+ {
65
+ match: { path: "/teapot" },
66
+ action: { setStatus: 418 },
67
+ },
68
+ ]);
69
+
70
+ const req = new Request("http://localhost/teapot");
71
+ const res = new Response("ok", { status: 200 });
72
+ const rewritten = rewriter.rewriteResponse(res, req);
73
+ assert.equal(rewritten.status, 418);
74
+ });
75
+
76
+ test("response rules only apply when request matches (bug fix)", () => {
77
+ const rewriter = createRewriter([
78
+ {
79
+ match: { path: "/admin" },
80
+ action: { setHeader: { "x-admin": "true" } },
81
+ },
82
+ ]);
83
+
84
+ const req = new Request("http://localhost/public");
85
+ const res = new Response("ok");
86
+ const rewritten = rewriter.rewriteResponse(res, req);
87
+ // Rule should NOT apply because /public doesn't match /admin
88
+ assert.equal(rewritten.headers.get("x-admin"), null);
89
+ // Response should be the same object (unchanged)
90
+ assert.equal(rewritten, res);
91
+ });
92
+
93
+ test("global rules (no match clause) apply to all responses", () => {
94
+ const rewriter = createRewriter([
95
+ {
96
+ action: { setHeader: { "x-global": "yes" } },
97
+ },
98
+ ]);
99
+
100
+ const req = new Request("http://localhost/anything");
101
+ const res = new Response("ok");
102
+ const rewritten = rewriter.rewriteResponse(res, req);
103
+ assert.equal(rewritten.headers.get("x-global"), "yes");
104
+ });
105
+
106
+ test("middleware wraps handler correctly", async () => {
107
+ const rewriter = createRewriter([
108
+ {
109
+ match: { path: /^\/old\/(.*)/ },
110
+ action: {
111
+ rewritePath: "/new/$1",
112
+ setHeader: { "x-rewritten": "true" },
113
+ },
114
+ },
115
+ ]);
116
+
117
+ // A handler that echoes back the pathname it received
118
+ const handler = async (request) => {
119
+ const url = new URL(request.url);
120
+ return new Response(url.pathname, { status: 200 });
121
+ };
122
+
123
+ const wrapped = rewriter.middleware(handler);
124
+ const req = new Request("http://localhost/old/stuff");
125
+ const res = await wrapped(req, {});
126
+
127
+ // Handler should have received the rewritten path
128
+ const body = await res.text();
129
+ assert.equal(body, "/new/stuff");
130
+ // Response should have the rewritten header
131
+ assert.equal(res.headers.get("x-rewritten"), "true");
132
+ });
133
+
134
+ test("addRule with /regex/ string conversion", () => {
135
+ const rewriter = createRewriter();
136
+ rewriter.addRule({
137
+ match: { path: "/^\\/api\\/(.*)/" },
138
+ action: { rewritePath: "/v2/$1" },
139
+ });
140
+
141
+ const rules = rewriter.getRules();
142
+ assert.equal(rules.length, 1);
143
+ assert.equal(rules[0].match.path, "^\\/api\\/(.*)");
144
+ });
145
+
146
+ test("addRule with invalid regex throws descriptive error", () => {
147
+ const rewriter = createRewriter();
148
+ assert.throws(
149
+ () => {
150
+ rewriter.addRule({
151
+ match: { path: "/[invalid/" },
152
+ action: {},
153
+ });
154
+ },
155
+ (err) => {
156
+ assert.ok(err.message.includes("Invalid regex pattern"));
157
+ return true;
158
+ }
159
+ );
160
+ });
161
+
162
+ test("removeRule / getRules CRUD", () => {
163
+ const rewriter = createRewriter();
164
+ rewriter.addRule({ match: { path: "/a" }, action: {} });
165
+ rewriter.addRule({ match: { path: "/b" }, action: {} });
166
+ rewriter.addRule({ match: { path: "/c" }, action: {} });
167
+
168
+ assert.equal(rewriter.getRules().length, 3);
169
+
170
+ rewriter.removeRule(1);
171
+ const rules = rewriter.getRules();
172
+ assert.equal(rules.length, 2);
173
+ assert.equal(rules[0].match.path, "/a");
174
+ assert.equal(rules[1].match.path, "/c");
175
+ });
176
+
177
+ test("removeRule ignores out-of-bounds index", () => {
178
+ const rewriter = createRewriter();
179
+ rewriter.addRule({ match: { path: "/a" }, action: {} });
180
+ rewriter.removeRule(5);
181
+ rewriter.removeRule(-1);
182
+ assert.equal(rewriter.getRules().length, 1);
183
+ });
184
+
185
+ test("method matching", () => {
186
+ const rewriter = createRewriter([
187
+ {
188
+ match: { method: "POST" },
189
+ action: { setHeader: { "x-method": "post" } },
190
+ },
191
+ ]);
192
+
193
+ const getReq = new Request("http://localhost/test", { method: "GET" });
194
+ const postReq = new Request("http://localhost/test", { method: "POST" });
195
+
196
+ const getResult = rewriter.rewriteRequest(getReq);
197
+ const postResult = rewriter.rewriteRequest(postReq);
198
+
199
+ assert.equal(getResult.headers.get("x-method"), null);
200
+ assert.equal(postResult.headers.get("x-method"), "post");
201
+ });
202
+ });
package/test/router.mjs CHANGED
@@ -171,6 +171,87 @@ test("Router - Function handler", async () => {
171
171
  assert.equal(response.status, 200);
172
172
  });
173
173
 
174
+ test("Router - mount delegates matching requests with the prefix stripped", async () => {
175
+ const router = createRouter();
176
+ const sub = createRouter();
177
+ sub.endpoint`GET /users``sub: ${(_, { params }) => "users"}`;
178
+ router.mount("/api", sub);
179
+
180
+ const response = await router(new Request("http://example.com/api/users"));
181
+ assert.equal(response.status, 200);
182
+ assert.equal(await response.text(), "sub: users");
183
+ });
184
+
185
+ test("Router - mount forwards mountPrefix to the sub-router's own handlers via ctx", async () => {
186
+ const router = createRouter();
187
+ const sub = createRouter();
188
+ sub.endpoint`GET /users`((_, ctx) => new Response(ctx.mountPrefix));
189
+ router.mount("/api", sub);
190
+
191
+ const response = await router(new Request("http://example.com/api/users"));
192
+ assert.equal(await response.text(), "/api");
193
+ });
194
+
195
+ test("Router - a caller-supplied ctx reaches matched handlers and defaultHandler alike", async () => {
196
+ const router = createRouter({
197
+ defaultHandler: (_, ctx) => new Response(`default:${ctx.tag}`),
198
+ });
199
+ router.endpoint`GET /hit`((_, ctx) => new Response(`hit:${ctx.tag}`));
200
+
201
+ const hit = await router(new Request("http://example.com/hit"), { tag: "x" });
202
+ assert.equal(await hit.text(), "hit:x");
203
+
204
+ const miss = await router(new Request("http://example.com/miss"), { tag: "y" });
205
+ assert.equal(await miss.text(), "default:y");
206
+ });
207
+
208
+ test("Router - mount matches the bare prefix itself (no trailing segment)", async () => {
209
+ const router = createRouter();
210
+ const sub = createRouter();
211
+ sub.endpoint`GET /``root`;
212
+ router.mount("/api", sub);
213
+
214
+ const response = await router(new Request("http://example.com/api"));
215
+ assert.equal(await response.text(), "root");
216
+ });
217
+
218
+ test("Router - mount falls through to the outer router's 404 for non-matching paths", async () => {
219
+ const router = createRouter();
220
+ const sub = createRouter();
221
+ sub.endpoint`GET /users``sub: users`;
222
+ router.mount("/api", sub);
223
+
224
+ const response = await router(new Request("http://example.com/other"));
225
+ assert.equal(response.status, 404);
226
+ });
227
+
228
+ test("Router - mount accepts an object exposing .fetch (so a router can mount another router)", async () => {
229
+ const router = createRouter();
230
+ const sub = createRouter();
231
+ sub.endpoint`GET /ping``pong`;
232
+ // `sub` is itself callable (Route & RouterExtension), but this exercises
233
+ // the `.fetch` branch explicitly by wrapping it in a plain object.
234
+ router.mount("/api", { fetch: sub });
235
+
236
+ const response = await router(new Request("http://example.com/api/ping"));
237
+ assert.equal(await response.text(), "pong");
238
+ });
239
+
240
+ test("Router - mount preserves query strings and method/body on the forwarded request", async () => {
241
+ const router = createRouter();
242
+ const sub = createRouter();
243
+ sub.endpoint`GET /search`(async (request) => {
244
+ const url = new URL(request.url);
245
+ return new Response(url.search);
246
+ });
247
+ router.mount("/api", sub);
248
+
249
+ const response = await router(
250
+ new Request("http://example.com/api/search?q=hello")
251
+ );
252
+ assert.equal(await response.text(), "?q=hello");
253
+ });
254
+
174
255
  // Commented out tests
175
256
  /*
176
257
  test("Router - InlineParam", async () => {
@@ -38,6 +38,7 @@ import {
38
38
  HTTPExpression,
39
39
  } from "@johnhenry/letterpress";
40
40
  import { createFSRouter } from "@johnhenry/letterpress/fs";
41
+ import { createRewriter } from "@johnhenry/letterpress/rewrite";
41
42
 
42
43
  // createRouter: a Router is a Route (request, context?) => Response, plus
43
44
  // an `.endpoint` tagged-template extension that registers routes.
@@ -136,3 +137,36 @@ async function checkFsRouter() {
136
137
  return response === null || response instanceof Response;
137
138
  }
138
139
  void checkFsRouter;
140
+
141
+ // createRewriter: only reachable via the "/rewrite" subpath (not the main
142
+ // barrel), mirroring createFSRouter's "/fs" subpath precedent. Ported from
143
+ // leroute -- see rewrite.mjs's top-of-file comment and CHANGELOG.md.
144
+ const rewriter = createRewriter([
145
+ {
146
+ match: { path: /^\/old\/(.*)/ },
147
+ action: { rewritePath: "/new/$1", setHeader: { "x-rewritten": "true" } },
148
+ },
149
+ ]);
150
+ const rewrittenRequest: Request = rewriter.rewriteRequest(
151
+ new Request("http://localhost/old/stuff")
152
+ );
153
+ void rewrittenRequest;
154
+ const rewrittenResponse: Response = rewriter.rewriteResponse(
155
+ new Response("ok"),
156
+ new Request("http://localhost/old/stuff")
157
+ );
158
+ void rewrittenResponse;
159
+ rewriter.addRule({ match: { path: "/teapot" }, action: { setStatus: 418 } });
160
+ rewriter.removeRule(0);
161
+ const ruleSnapshots = rewriter.getRules();
162
+ const firstRuleIndex: number = ruleSnapshots[0]?.index ?? -1;
163
+ void firstRuleIndex;
164
+ const wrapped = rewriter.middleware(async (request: Request) => {
165
+ void request;
166
+ return new Response("ok");
167
+ });
168
+ async function checkRewriterMiddleware() {
169
+ const response = await wrapped(new Request("http://localhost/old/stuff"), {});
170
+ return response instanceof Response;
171
+ }
172
+ void checkRewriterMiddleware;
package/tsconfig.json CHANGED
@@ -15,6 +15,7 @@
15
15
  "include": [
16
16
  "types.d.ts",
17
17
  "fs.d.ts",
18
+ "rewrite.d.ts",
18
19
  "test/types/**/*.ts"
19
20
  ]
20
21
  }
package/types.d.ts CHANGED
@@ -19,12 +19,18 @@ export type RouterEndpointResponder = {
19
19
  (handler: Route): Router;
20
20
  };
21
21
 
22
+ // A handler mountable under a Router prefix: a plain (Request) => Response
23
+ // function, or an object exposing one as `.fetch` (so a Router can mount
24
+ // another Router).
25
+ export type Mountable = Route | { fetch: Route };
26
+
22
27
  // Extension for the Router to add endpoints
23
28
  export type RouterExtension = {
24
29
  endpoint: (
25
30
  template: TemplateStringsArray,
26
31
  ...substitutions: any[]
27
32
  ) => RouterEndpointResponder;
33
+ mount: (prefix: string, subHandler: Mountable) => Router;
28
34
  };
29
35
 
30
36
  // Combines Route and RouterExtension