@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/test/route.mjs
CHANGED
|
@@ -87,6 +87,43 @@ describe("createRoute", () => {
|
|
|
87
87
|
assert.strictEqual(await response.text(), "STREAMED");
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
+
it("should accumulate repeated header names instead of overwriting (letterpress #8)", async () => {
|
|
91
|
+
// Regression test: headers.set() was called for every header line
|
|
92
|
+
// found while parsing the template, so a second Set-Cookie line
|
|
93
|
+
// silently overwrote the first instead of both surviving.
|
|
94
|
+
const route = createRoute()`HTTP/1.1 200 OK
|
|
95
|
+
Set-Cookie: a=1
|
|
96
|
+
Set-Cookie: b=2
|
|
97
|
+
Content-Type: text/plain
|
|
98
|
+
|
|
99
|
+
hello`;
|
|
100
|
+
const response = await route(new Request("https://example.com"));
|
|
101
|
+
const cookies = response.headers.getSetCookie
|
|
102
|
+
? response.headers.getSetCookie()
|
|
103
|
+
: [...response.headers.entries()]
|
|
104
|
+
.filter(([key]) => key === "set-cookie")
|
|
105
|
+
.map(([, value]) => value);
|
|
106
|
+
assert.deepStrictEqual(cookies, ["a=1", "b=2"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("should not reinterpret a substituted value as new header syntax (letterpress #8)", async () => {
|
|
110
|
+
// Regression test: a stringified substitution was re-fed through the
|
|
111
|
+
// same line tokenizer used for the template's own literal text, so a
|
|
112
|
+
// substitution containing "\n" or ":" could end the header section
|
|
113
|
+
// early or appear to start a new header line.
|
|
114
|
+
const route = createRoute()`HTTP/1.1 200 OK
|
|
115
|
+
X-Note: ${"value: with: colons: inside"}
|
|
116
|
+
Content-Type: text/plain
|
|
117
|
+
|
|
118
|
+
${"body text"}`;
|
|
119
|
+
const response = await route(new Request("https://example.com"));
|
|
120
|
+
assert.strictEqual(
|
|
121
|
+
response.headers.get("X-Note"),
|
|
122
|
+
"value: with: colons: inside"
|
|
123
|
+
);
|
|
124
|
+
assert.strictEqual(await response.text(), "body text");
|
|
125
|
+
});
|
|
126
|
+
|
|
90
127
|
it("should handle streaming responses", async () => {
|
|
91
128
|
const route = createRoute({ streaming: true })`
|
|
92
129
|
${async (_, { response }) => {
|
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 () => {
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import { strict as assert } from "node:assert";
|
|
3
|
+
import { tagRequest, tagResponse } from "../index.mjs";
|
|
4
|
+
|
|
5
|
+
const createReadableStream = (data) =>
|
|
6
|
+
new ReadableStream({
|
|
7
|
+
start(controller) {
|
|
8
|
+
controller.enqueue(new TextEncoder().encode(data));
|
|
9
|
+
controller.close();
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
await test("tagRequest", async (t) => {
|
|
14
|
+
await t.test("basic request, Host header from template", async () => {
|
|
15
|
+
const request = await tagRequest()`POST /users HTTP/1.1
|
|
16
|
+
Host: example.com
|
|
17
|
+
Content-Type: application/x-www-form-urlencoded
|
|
18
|
+
|
|
19
|
+
name=A+B&email=a%40b.com`;
|
|
20
|
+
assert.equal(request.method, "POST");
|
|
21
|
+
// Host: example.com in the template sets the Host *header* -- it does
|
|
22
|
+
// not change which baseUrl a relative "/users" path resolves against
|
|
23
|
+
// (that's the separate `baseUrl` option, matching createRequest).
|
|
24
|
+
assert.equal(request.url, "http://localhost/users");
|
|
25
|
+
assert.equal(request.headers.get("host"), "example.com");
|
|
26
|
+
assert.equal(
|
|
27
|
+
request.headers.get("content-type"),
|
|
28
|
+
"application/x-www-form-urlencoded"
|
|
29
|
+
);
|
|
30
|
+
assert.equal(await request.text(), "name=A+B&email=a%40b.com");
|
|
31
|
+
assert.equal(request.headers.get("content-length"), "24");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
await t.test("host sugar key", async () => {
|
|
35
|
+
const request = await tagRequest({ host: "example.com" })`GET /path HTTP/1.1
|
|
36
|
+
Accept: application/json`;
|
|
37
|
+
assert.equal(request.headers.get("host"), "example.com");
|
|
38
|
+
assert.equal(request.headers.get("accept"), "application/json");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
await t.test("host sugar key conflicts with explicit headers.host -- throws", () => {
|
|
42
|
+
assert.throws(
|
|
43
|
+
() => tagRequest({ host: "a", headers: { Host: "b" } }),
|
|
44
|
+
TypeError
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
await t.test(
|
|
49
|
+
"defaults.headers is a fallback, template headers win per-key",
|
|
50
|
+
async () => {
|
|
51
|
+
const request = await tagRequest({
|
|
52
|
+
headers: { "X-Default": "from-defaults", "Content-Type": "text/x-default" },
|
|
53
|
+
})`POST /x HTTP/1.1
|
|
54
|
+
Content-Type: text/plain
|
|
55
|
+
|
|
56
|
+
body`;
|
|
57
|
+
assert.equal(request.headers.get("x-default"), "from-defaults");
|
|
58
|
+
assert.equal(request.headers.get("content-type"), "text/plain");
|
|
59
|
+
}
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
await t.test("missing request line throws", async () => {
|
|
63
|
+
await assert.rejects(
|
|
64
|
+
() => tagRequest()`Content-Type: text/plain
|
|
65
|
+
|
|
66
|
+
oops`,
|
|
67
|
+
TypeError
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await t.test(
|
|
72
|
+
"a substituted header value containing a colon is not truncated",
|
|
73
|
+
async () => {
|
|
74
|
+
const request = await tagRequest()`POST /x HTTP/1.1
|
|
75
|
+
X-Note: ${"value: with: colons: inside"}
|
|
76
|
+
Content-Type: text/plain
|
|
77
|
+
|
|
78
|
+
body`;
|
|
79
|
+
assert.equal(
|
|
80
|
+
request.headers.get("x-note"),
|
|
81
|
+
"value: with: colons: inside"
|
|
82
|
+
);
|
|
83
|
+
assert.equal(await request.text(), "body");
|
|
84
|
+
}
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
await t.test(
|
|
88
|
+
"a substituted value cannot inject a new header or end headers early",
|
|
89
|
+
async () => {
|
|
90
|
+
// If the parser re-scanned this substitution's text for structure,
|
|
91
|
+
// it would register a second "injected" header and/or end the
|
|
92
|
+
// header section early. The platform's Headers validation rejects
|
|
93
|
+
// a raw embedded newline in a header value outright, which is the
|
|
94
|
+
// correct outcome -- it proves the value was handed to Headers.set
|
|
95
|
+
// as one literal string, not re-tokenized into separate lines.
|
|
96
|
+
await assert.rejects(
|
|
97
|
+
() =>
|
|
98
|
+
tagRequest()`POST /x HTTP/1.1
|
|
99
|
+
X-Note: ${"line1\ninjected: value"}
|
|
100
|
+
Content-Type: text/plain
|
|
101
|
+
|
|
102
|
+
body`,
|
|
103
|
+
TypeError
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
await t.test("repeated header name accumulates (not last-wins)", async () => {
|
|
109
|
+
const request = await tagRequest()`POST /x HTTP/1.1
|
|
110
|
+
X-Tag: a
|
|
111
|
+
X-Tag: b
|
|
112
|
+
Content-Type: text/plain
|
|
113
|
+
|
|
114
|
+
body`;
|
|
115
|
+
// Headers folds repeated non-Set-Cookie values with ", " on .get() --
|
|
116
|
+
// the point here is both survive, rather than "b" silently winning.
|
|
117
|
+
assert.equal(request.headers.get("x-tag"), "a, b");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await t.test("Headers object substitution merges directly", async () => {
|
|
121
|
+
const extra = new Headers({ "X-From-Headers-Object": "yes" });
|
|
122
|
+
const request = await tagRequest()`POST /x HTTP/1.1
|
|
123
|
+
${extra}
|
|
124
|
+
Content-Type: text/plain
|
|
125
|
+
|
|
126
|
+
body`;
|
|
127
|
+
assert.equal(request.headers.get("x-from-headers-object"), "yes");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await t.test("binary body passthrough (Uint8Array)", async () => {
|
|
131
|
+
const bin = new Uint8Array([1, 2, 3]);
|
|
132
|
+
const request = await tagRequest()`POST /up HTTP/1.1
|
|
133
|
+
|
|
134
|
+
${bin}`;
|
|
135
|
+
const buf = await request.arrayBuffer();
|
|
136
|
+
assert.deepEqual([...new Uint8Array(buf)], [1, 2, 3]);
|
|
137
|
+
assert.equal(request.headers.get("content-type"), "application/octet-stream");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await t.test("binary body passthrough (ReadableStream)", async () => {
|
|
141
|
+
const request = await tagRequest()`POST /up HTTP/1.1
|
|
142
|
+
|
|
143
|
+
${createReadableStream("streamed")}`;
|
|
144
|
+
assert.equal(await request.text(), "streamed");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await t.test(
|
|
148
|
+
"binary body substitution mixed with other body content throws",
|
|
149
|
+
async () => {
|
|
150
|
+
const bin = new Uint8Array([1, 2, 3]);
|
|
151
|
+
await assert.rejects(
|
|
152
|
+
() => tagRequest()`POST /up HTTP/1.1
|
|
153
|
+
|
|
154
|
+
prefix ${bin}`,
|
|
155
|
+
TypeError
|
|
156
|
+
);
|
|
157
|
+
await assert.rejects(
|
|
158
|
+
() => tagRequest()`POST /up HTTP/1.1
|
|
159
|
+
|
|
160
|
+
${bin}${new Uint8Array([9])}`,
|
|
161
|
+
TypeError
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
await t.test("Content-Length auto-filled only when absent", async () => {
|
|
167
|
+
const auto = await tagRequest()`POST /x HTTP/1.1
|
|
168
|
+
Content-Type: text/plain
|
|
169
|
+
|
|
170
|
+
hello`;
|
|
171
|
+
assert.equal(auto.headers.get("content-length"), "5");
|
|
172
|
+
|
|
173
|
+
const explicit = await tagRequest()`POST /x HTTP/1.1
|
|
174
|
+
Content-Type: text/plain
|
|
175
|
+
Content-Length: 999
|
|
176
|
+
|
|
177
|
+
hello`;
|
|
178
|
+
assert.equal(
|
|
179
|
+
explicit.headers.get("content-length"),
|
|
180
|
+
"999",
|
|
181
|
+
"an explicitly-declared Content-Length is never overwritten, even if wrong"
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await t.test("relative vs absolute URLs, baseUrl option", async () => {
|
|
186
|
+
const relative = await tagRequest({ baseUrl: "https://api.example.com" })`GET /search?q=1 HTTP/1.1`;
|
|
187
|
+
assert.equal(relative.url, "https://api.example.com/search?q=1");
|
|
188
|
+
|
|
189
|
+
const absolute = await tagRequest()`DELETE https://api.example.com/users/1 HTTP/1.1`;
|
|
190
|
+
assert.equal(absolute.url, "https://api.example.com/users/1");
|
|
191
|
+
assert.equal(absolute.method, "DELETE");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await test("tagResponse", async (t) => {
|
|
196
|
+
await t.test("basic response", async () => {
|
|
197
|
+
const response = await tagResponse`HTTP/1.1 201 Created
|
|
198
|
+
Content-Type: application/json
|
|
199
|
+
Location: http://example.com/users/123
|
|
200
|
+
|
|
201
|
+
{"id":123}`;
|
|
202
|
+
assert.equal(response.status, 201);
|
|
203
|
+
assert.equal(response.statusText, "Created");
|
|
204
|
+
assert.equal(response.headers.get("location"), "http://example.com/users/123");
|
|
205
|
+
assert.deepEqual(await response.json(), { id: 123 });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await t.test("missing status line throws", async () => {
|
|
209
|
+
await assert.rejects(
|
|
210
|
+
() => tagResponse`Content-Type: text/plain
|
|
211
|
+
|
|
212
|
+
oops`,
|
|
213
|
+
TypeError
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await t.test("repeated Set-Cookie accumulates, not last-wins", async () => {
|
|
218
|
+
const response = await tagResponse`HTTP/1.1 200 OK
|
|
219
|
+
Set-Cookie: a=1
|
|
220
|
+
Set-Cookie: b=2
|
|
221
|
+
Content-Type: text/plain
|
|
222
|
+
|
|
223
|
+
hello`;
|
|
224
|
+
const cookies = response.headers.getSetCookie
|
|
225
|
+
? response.headers.getSetCookie()
|
|
226
|
+
: [...response.headers.entries()]
|
|
227
|
+
.filter(([k]) => k === "set-cookie")
|
|
228
|
+
.map(([, v]) => v);
|
|
229
|
+
assert.deepEqual(cookies, ["a=1", "b=2"]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await t.test("204 has a null body", async () => {
|
|
233
|
+
const response = await tagResponse`HTTP/1.1 204 No Content
|
|
234
|
+
`;
|
|
235
|
+
assert.equal(response.status, 204);
|
|
236
|
+
assert.equal(await response.text(), "");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await t.test("binary body passthrough (Blob)", async () => {
|
|
240
|
+
const response = await tagResponse`HTTP/1.1 200 OK
|
|
241
|
+
|
|
242
|
+
${new Blob(["hello blob"], { type: "text/plain" })}`;
|
|
243
|
+
assert.equal(response.headers.get("content-type"), "text/plain");
|
|
244
|
+
assert.equal(await response.text(), "hello blob");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
await t.test("Content-Length auto-filled only when absent", async () => {
|
|
248
|
+
const auto = await tagResponse`HTTP/1.1 200 OK
|
|
249
|
+
|
|
250
|
+
hello`;
|
|
251
|
+
assert.equal(auto.headers.get("content-length"), "5");
|
|
252
|
+
});
|
|
253
|
+
});
|
package/test/types/fixture.ts
CHANGED
|
@@ -33,11 +33,14 @@ import {
|
|
|
33
33
|
createRoute,
|
|
34
34
|
createRequest,
|
|
35
35
|
createResponse,
|
|
36
|
+
tagRequest,
|
|
37
|
+
tagResponse,
|
|
36
38
|
deconstruct,
|
|
37
39
|
cook,
|
|
38
40
|
HTTPExpression,
|
|
39
41
|
} from "@johnhenry/letterpress";
|
|
40
42
|
import { createFSRouter } from "@johnhenry/letterpress/fs";
|
|
43
|
+
import { createRewriter } from "@johnhenry/letterpress/rewrite";
|
|
41
44
|
|
|
42
45
|
// createRouter: a Router is a Route (request, context?) => Response, plus
|
|
43
46
|
// an `.endpoint` tagged-template extension that registers routes.
|
|
@@ -100,6 +103,24 @@ ok`;
|
|
|
100
103
|
}
|
|
101
104
|
void checkResponse;
|
|
102
105
|
|
|
106
|
+
// tagRequest: (defaults?) => tagged-template => Promise<Request>
|
|
107
|
+
async function checkTagRequest() {
|
|
108
|
+
const request = await tagRequest({ host: "example.com" })`GET /path HTTP/1.1
|
|
109
|
+
Accept: application/json`;
|
|
110
|
+
return request instanceof Request;
|
|
111
|
+
}
|
|
112
|
+
void checkTagRequest;
|
|
113
|
+
|
|
114
|
+
// tagResponse: itself a tagged-template function => Promise<Response>
|
|
115
|
+
async function checkTagResponse() {
|
|
116
|
+
const response = await tagResponse`HTTP/1.1 200 OK
|
|
117
|
+
Content-Type: text/plain
|
|
118
|
+
|
|
119
|
+
ok`;
|
|
120
|
+
return response instanceof Response;
|
|
121
|
+
}
|
|
122
|
+
void checkTagResponse;
|
|
123
|
+
|
|
103
124
|
// deconstruct: tagged-template => { strings, substitutions, raw }
|
|
104
125
|
const deconstructed = deconstruct`GET /${"foo"}`;
|
|
105
126
|
const deconstructedStrings: string[] = deconstructed.strings;
|
|
@@ -136,3 +157,36 @@ async function checkFsRouter() {
|
|
|
136
157
|
return response === null || response instanceof Response;
|
|
137
158
|
}
|
|
138
159
|
void checkFsRouter;
|
|
160
|
+
|
|
161
|
+
// createRewriter: only reachable via the "/rewrite" subpath (not the main
|
|
162
|
+
// barrel), mirroring createFSRouter's "/fs" subpath precedent. Ported from
|
|
163
|
+
// leroute -- see rewrite.mjs's top-of-file comment and CHANGELOG.md.
|
|
164
|
+
const rewriter = createRewriter([
|
|
165
|
+
{
|
|
166
|
+
match: { path: /^\/old\/(.*)/ },
|
|
167
|
+
action: { rewritePath: "/new/$1", setHeader: { "x-rewritten": "true" } },
|
|
168
|
+
},
|
|
169
|
+
]);
|
|
170
|
+
const rewrittenRequest: Request = rewriter.rewriteRequest(
|
|
171
|
+
new Request("http://localhost/old/stuff")
|
|
172
|
+
);
|
|
173
|
+
void rewrittenRequest;
|
|
174
|
+
const rewrittenResponse: Response = rewriter.rewriteResponse(
|
|
175
|
+
new Response("ok"),
|
|
176
|
+
new Request("http://localhost/old/stuff")
|
|
177
|
+
);
|
|
178
|
+
void rewrittenResponse;
|
|
179
|
+
rewriter.addRule({ match: { path: "/teapot" }, action: { setStatus: 418 } });
|
|
180
|
+
rewriter.removeRule(0);
|
|
181
|
+
const ruleSnapshots = rewriter.getRules();
|
|
182
|
+
const firstRuleIndex: number = ruleSnapshots[0]?.index ?? -1;
|
|
183
|
+
void firstRuleIndex;
|
|
184
|
+
const wrapped = rewriter.middleware(async (request: Request) => {
|
|
185
|
+
void request;
|
|
186
|
+
return new Response("ok");
|
|
187
|
+
});
|
|
188
|
+
async function checkRewriterMiddleware() {
|
|
189
|
+
const response = await wrapped(new Request("http://localhost/old/stuff"), {});
|
|
190
|
+
return response instanceof Response;
|
|
191
|
+
}
|
|
192
|
+
void checkRewriterMiddleware;
|
package/tsconfig.json
CHANGED
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
|
|
@@ -124,6 +130,42 @@ export type CreateResponse = (
|
|
|
124
130
|
...substitutions: any[]
|
|
125
131
|
) => Response | Promise<Response>;
|
|
126
132
|
|
|
133
|
+
// Configuration/sugar for tagRequest(). Unlike CreateRequestInit, these
|
|
134
|
+
// fields only ever fill in what raw HTTP text can't express -- method,
|
|
135
|
+
// URL, status, and body always come from the parsed template itself, not
|
|
136
|
+
// from defaults. `host` is sugar for headers.host; providing both throws.
|
|
137
|
+
export type TagRequestDefaults = {
|
|
138
|
+
headers?: HeadersInit | Headers;
|
|
139
|
+
host?: string;
|
|
140
|
+
baseUrl?: string;
|
|
141
|
+
redirect?: RequestRedirect;
|
|
142
|
+
credentials?: RequestCredentials;
|
|
143
|
+
mode?: RequestMode;
|
|
144
|
+
referrer?: string;
|
|
145
|
+
referrerPolicy?: ReferrerPolicy;
|
|
146
|
+
signal?: AbortSignal;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Tagged-template function returned by tagRequest(): a strict, from-scratch
|
|
150
|
+
// parser of raw HTTP-request text (see README's "which one do I want?"
|
|
151
|
+
// note comparing this to createRequest). Substitutions are spliced in
|
|
152
|
+
// verbatim -- never re-scanned for "\n"/":" -- and a binary-typed
|
|
153
|
+
// substitution (Blob/Uint8Array/ArrayBuffer/ReadableStream/FormData/
|
|
154
|
+
// URLSearchParams) must be the sole content of the body.
|
|
155
|
+
export type TagRequestTag = (
|
|
156
|
+
template: TemplateStringsArray,
|
|
157
|
+
...substitutions: any[]
|
|
158
|
+
) => Promise<Request>;
|
|
159
|
+
|
|
160
|
+
export type TagRequest = (defaults?: TagRequestDefaults) => TagRequestTag;
|
|
161
|
+
|
|
162
|
+
// tagResponse is itself the tagged-template function (uncurried, like
|
|
163
|
+
// createResponse) -- there is no defaults-currying form in this version.
|
|
164
|
+
export type TagResponse = (
|
|
165
|
+
template: TemplateStringsArray,
|
|
166
|
+
...substitutions: any[]
|
|
167
|
+
) => Promise<Response>;
|
|
168
|
+
|
|
127
169
|
// Result of deconstruct(): the raw pieces of a tagged template call.
|
|
128
170
|
export type DeconstructResult = {
|
|
129
171
|
strings: string[];
|
|
@@ -180,6 +222,8 @@ export declare const createRouter: CreateRouter;
|
|
|
180
222
|
export declare const createRoute: CreateRoute;
|
|
181
223
|
export declare const createRequest: CreateRequest;
|
|
182
224
|
export declare const createResponse: CreateResponse;
|
|
225
|
+
export declare const tagRequest: TagRequest;
|
|
226
|
+
export declare const tagResponse: TagResponse;
|
|
183
227
|
export declare const deconstruct: Deconstruct;
|
|
184
228
|
export declare const cook: Cook;
|
|
185
229
|
export declare const HTTPExpression: HTTPExpressionFn;
|