@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/API.md +57 -1
- package/CHANGELOG.md +127 -0
- package/README.md +87 -3
- package/create-route.mjs +62 -125
- package/create-router.mjs +50 -3
- package/index.mjs +2 -0
- package/package.json +6 -2
- package/rewrite.d.ts +61 -0
- package/rewrite.mjs +176 -0
- package/tag-request.mjs +100 -0
- package/tag-response.mjs +73 -0
- package/test/exports.mjs +28 -0
- package/test/index.mjs +2 -0
- package/test/rewrite.mjs +202 -0
- package/test/route.mjs +37 -0
- package/test/router.mjs +81 -0
- package/test/tag-req-res.mjs +253 -0
- package/test/types/fixture.ts +54 -0
- package/tsconfig.json +1 -0
- package/types.d.ts +44 -0
- package/utility/parse-http-text.mjs +164 -0
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/tag-request.mjs
ADDED
|
@@ -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;
|
package/tag-response.mjs
ADDED
|
@@ -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";
|
package/test/rewrite.mjs
ADDED
|
@@ -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
|
+
});
|