@johnhenry/letterpress 0.0.3 → 0.2.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/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;
@@ -0,0 +1,100 @@
1
+ import { parseHttpText } from "./utility/parse-http-text.mjs";
2
+
3
+ const inferBodyContentType = (body) => {
4
+ if (body instanceof ReadableStream) return "application/octet-stream";
5
+ if (body instanceof Blob) return body.type || "application/octet-stream";
6
+ if (body instanceof URLSearchParams)
7
+ return "application/x-www-form-urlencoded";
8
+ if (body instanceof ArrayBuffer || body instanceof Uint8Array)
9
+ return "application/octet-stream";
10
+ return null; // FormData sets its own; plain strings get no guessed type
11
+ };
12
+
13
+ const mergeHeaderEntries = (baseHeaders, headerEntries) => {
14
+ const seen = new Set();
15
+ for (const { name, value } of headerEntries) {
16
+ const key = name.toLowerCase();
17
+ if (seen.has(key)) {
18
+ baseHeaders.append(name, value);
19
+ } else {
20
+ baseHeaders.set(name, value);
21
+ seen.add(key);
22
+ }
23
+ }
24
+ return baseHeaders;
25
+ };
26
+
27
+ const PASSTHROUGH_REQUEST_INIT_KEYS = [
28
+ "redirect",
29
+ "credentials",
30
+ "mode",
31
+ "referrer",
32
+ "referrerPolicy",
33
+ "signal",
34
+ ];
35
+
36
+ export const tagRequest = (defaults = {}) => {
37
+ const baseHeaders = new Headers(defaults.headers);
38
+ if (defaults.host !== undefined) {
39
+ if (baseHeaders.has("host")) {
40
+ throw new TypeError(
41
+ "tagRequest: both the 'host' sugar key and headers.host/Host were given in defaults -- remove one"
42
+ );
43
+ }
44
+ baseHeaders.set("host", defaults.host);
45
+ }
46
+
47
+ return async function (strings, ...substitutions) {
48
+ const { startLine, headerEntries, body } = parseHttpText(
49
+ strings,
50
+ substitutions
51
+ );
52
+ const [method, url, httpVersion] = startLine.split(" ");
53
+ if (!method || !url || !httpVersion) {
54
+ throw new TypeError(
55
+ `tagRequest: template must start with a request line ("METHOD /path HTTP/1.1"), got: ${JSON.stringify(
56
+ startLine
57
+ )}`
58
+ );
59
+ }
60
+
61
+ const baseUrl = defaults.baseUrl || "http://localhost";
62
+ const fullUrl =
63
+ url.startsWith("http://") || url.startsWith("https://")
64
+ ? new URL(url)
65
+ : new URL(url.startsWith("/") ? url : `/${url}`, baseUrl);
66
+
67
+ const headers = mergeHeaderEntries(new Headers(baseHeaders), headerEntries);
68
+
69
+ const requestInit = { method, headers };
70
+ for (const key of PASSTHROUGH_REQUEST_INIT_KEYS) {
71
+ if (defaults[key] !== undefined) requestInit[key] = defaults[key];
72
+ }
73
+
74
+ const isBinaryBody = typeof body !== "string";
75
+ if (isBinaryBody) {
76
+ requestInit.body = body;
77
+ if (body instanceof ReadableStream) requestInit.duplex = "half";
78
+ if (!headers.has("Content-Type")) {
79
+ const contentType = inferBodyContentType(body);
80
+ if (contentType) headers.set("Content-Type", contentType);
81
+ }
82
+ } else if (body) {
83
+ requestInit.body = body;
84
+ }
85
+
86
+ if (requestInit.body && !headers.has("Content-Length")) {
87
+ const size =
88
+ requestInit.body instanceof Blob
89
+ ? requestInit.body.size
90
+ : typeof requestInit.body === "string"
91
+ ? new Blob([requestInit.body]).size
92
+ : null;
93
+ if (size !== null) headers.set("Content-Length", size.toString());
94
+ }
95
+
96
+ return new Request(fullUrl, requestInit);
97
+ };
98
+ };
99
+
100
+ export default tagRequest;
@@ -0,0 +1,73 @@
1
+ import { parseHttpText } from "./utility/parse-http-text.mjs";
2
+
3
+ const inferBodyContentType = (body) => {
4
+ if (body instanceof ReadableStream) return "application/octet-stream";
5
+ if (body instanceof Blob) return body.type || "application/octet-stream";
6
+ if (body instanceof URLSearchParams)
7
+ return "application/x-www-form-urlencoded";
8
+ if (body instanceof ArrayBuffer || body instanceof Uint8Array)
9
+ return "application/octet-stream";
10
+ return null;
11
+ };
12
+
13
+ const mergeHeaderEntries = (headerEntries) => {
14
+ const headers = new Headers();
15
+ const seen = new Set();
16
+ for (const { name, value } of headerEntries) {
17
+ const key = name.toLowerCase();
18
+ if (seen.has(key)) {
19
+ headers.append(name, value);
20
+ } else {
21
+ headers.set(name, value);
22
+ seen.add(key);
23
+ }
24
+ }
25
+ return headers;
26
+ };
27
+
28
+ export const tagResponse = async function (strings, ...substitutions) {
29
+ const { startLine, headerEntries, body } = parseHttpText(
30
+ strings,
31
+ substitutions
32
+ );
33
+ const [httpVersion, statusCodeText, ...statusTextParts] =
34
+ startLine.split(" ");
35
+ const status = parseInt(statusCodeText, 10);
36
+ if (!httpVersion?.startsWith("HTTP/") || Number.isNaN(status)) {
37
+ throw new TypeError(
38
+ `tagResponse: template must start with a status line ("HTTP/1.1 200 OK"), got: ${JSON.stringify(
39
+ startLine
40
+ )}`
41
+ );
42
+ }
43
+ const statusText = statusTextParts.join(" ");
44
+
45
+ const headers = mergeHeaderEntries(headerEntries);
46
+
47
+ const isBinaryBody = typeof body !== "string";
48
+ let responseBody = body;
49
+ if (isBinaryBody) {
50
+ if (!headers.has("Content-Type")) {
51
+ const contentType = inferBodyContentType(body);
52
+ if (contentType) headers.set("Content-Type", contentType);
53
+ }
54
+ }
55
+
56
+ if (responseBody && !headers.has("Content-Length")) {
57
+ const size =
58
+ responseBody instanceof Blob
59
+ ? responseBody.size
60
+ : typeof responseBody === "string"
61
+ ? new Blob([responseBody]).size
62
+ : null;
63
+ if (size !== null) headers.set("Content-Length", size.toString());
64
+ }
65
+
66
+ return new Response(status === 204 && !responseBody ? null : responseBody, {
67
+ status,
68
+ statusText,
69
+ headers,
70
+ });
71
+ };
72
+
73
+ export default tagResponse;
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
@@ -2,7 +2,9 @@ import "./router.mjs";
2
2
  import "./http-expression.mjs";
3
3
  import "./route.mjs";
4
4
  import "./req-res.mjs";
5
+ import "./tag-req-res.mjs";
5
6
  import "./fs-router.mjs";
7
+ import "./rewrite.mjs";
6
8
  import "./dynamic-run.mjs";
7
9
  import "./exports.mjs";
8
10
  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
+ });