@johnhenry/letterpress 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,289 @@
1
+ import { test } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { createRequest, createResponse } from "../index.mjs";
4
+
5
+ // Utility function to create a simple readable stream
6
+ const createReadableStream = (data) => {
7
+ return new ReadableStream({
8
+ start(controller) {
9
+ controller.enqueue(new TextEncoder().encode(data));
10
+ controller.close();
11
+ },
12
+ });
13
+ };
14
+
15
+ await test("createRequest function tests", async (t) => {
16
+ await t.test("Basic GET request", async () => {
17
+ const request = await createRequest({ baseUrl: "http://example.com" })`
18
+ GET /api/data HTTP/1.1
19
+ User-Agent: TestAgent/1.0
20
+ Accept: application/json
21
+ `;
22
+ assert.equal(request.method, "GET");
23
+ assert.equal(request.url, "http://example.com/api/data");
24
+ assert.equal(request.headers.get("User-Agent"), "TestAgent/1.0");
25
+ assert.equal(request.headers.get("Accept"), "application/json");
26
+ });
27
+
28
+ await t.test("POST request with various body types", async (t) => {
29
+ const testCases = [
30
+ {
31
+ name: "JSON body",
32
+ body: JSON.stringify({ name: "John Doe", email: "john@example.com" }),
33
+ contentType: "application/json",
34
+ assert: async (req) => {
35
+ const body = await req.json();
36
+ assert.deepEqual(body, {
37
+ name: "John Doe",
38
+ email: "john@example.com",
39
+ });
40
+ },
41
+ },
42
+ {
43
+ name: "URLSearchParams body",
44
+ body: new URLSearchParams({ key1: "value1", key2: "value2" }),
45
+ contentType: "application/x-www-form-urlencoded",
46
+ assert: async (req) => {
47
+ const body = await req.text();
48
+ assert.equal(body, "key1=value1&key2=value2");
49
+ },
50
+ },
51
+ {
52
+ name: "FormData body",
53
+ body: (() => {
54
+ const formData = new FormData();
55
+ formData.append("username", "testuser");
56
+ formData.append("password", "testpass");
57
+ return formData;
58
+ })(),
59
+ contentType: "multipart/form-data",
60
+ assert: async (req) => {
61
+ assert.ok(
62
+ req.headers.get("Content-Type").startsWith("multipart/form-data")
63
+ );
64
+ },
65
+ },
66
+ {
67
+ name: "Uint8Array body",
68
+ body: new Uint8Array([1, 2, 3, 4, 5]),
69
+ contentType: "application/octet-stream",
70
+ assert: async (req) => {
71
+ const bodyBuffer = await req.arrayBuffer();
72
+ assert.deepEqual(
73
+ new Uint8Array(bodyBuffer),
74
+ new Uint8Array([1, 2, 3, 4, 5])
75
+ );
76
+ },
77
+ },
78
+ {
79
+ name: "ReadableStream body",
80
+ body: createReadableStream("This is streaming data"),
81
+ contentType: "application/octet-stream",
82
+ assert: async (req) => {
83
+ const bodyText = await req.text();
84
+ assert.equal(bodyText, "This is streaming data");
85
+ },
86
+ },
87
+ ];
88
+
89
+ for (const testCase of testCases) {
90
+ await t.test(`POST request with ${testCase.name}`, async () => {
91
+ const request = await createRequest({
92
+ baseUrl: "https://api.example.com",
93
+ })`
94
+ POST /data HTTP/1.1
95
+ User-Agent: TestAgent/1.0
96
+
97
+ ${testCase.body}
98
+ `;
99
+ assert.equal(request.method, "POST");
100
+ assert.equal(request.url, "https://api.example.com/data");
101
+ assert.ok(
102
+ request.headers.get("Content-Type").startsWith(testCase.contentType)
103
+ );
104
+ await testCase.assert(request);
105
+ });
106
+ }
107
+ });
108
+
109
+ await t.test("PUT request", async () => {
110
+ const request = await createRequest()`
111
+ PUT https://api.example.com/update HTTP/1.1
112
+ User-Agent: TestAgent/1.0
113
+
114
+ ${new URLSearchParams({ key1: "value1", key2: "value2" })}
115
+ `;
116
+ assert.equal(request.method, "PUT");
117
+ assert.equal(request.url, "https://api.example.com/update");
118
+ assert.equal(
119
+ request.headers.get("Content-Type"),
120
+ "application/x-www-form-urlencoded"
121
+ );
122
+ const bodyText = await request.text();
123
+ assert.equal(bodyText, "key1=value1&key2=value2");
124
+ });
125
+
126
+ await t.test("DELETE request", async () => {
127
+ const request = await createRequest()`
128
+ DELETE https://api.example.com/users/123 HTTP/1.1
129
+ Authorization: Bearer token123
130
+ `;
131
+ assert.equal(request.method, "DELETE");
132
+ assert.equal(request.url, "https://api.example.com/users/123");
133
+ assert.equal(request.headers.get("Authorization"), "Bearer token123");
134
+ assert.equal(await request.text(), "");
135
+ });
136
+
137
+ await t.test("GET request with query parameters", async () => {
138
+ const request = await createRequest()`
139
+ GET https://api.example.com/search?q=test&page=1 HTTP/1.1
140
+ Accept: application/json
141
+ `;
142
+ assert.equal(request.method, "GET");
143
+ assert.equal(request.url, "https://api.example.com/search?q=test&page=1");
144
+ assert.equal(request.headers.get("Accept"), "application/json");
145
+ });
146
+ });
147
+
148
+ await test("createResponse function tests", async (t) => {
149
+ await t.test("Response with various status codes and bodies", async (t) => {
150
+ const testCases = [
151
+ {
152
+ name: "HTML response",
153
+ status: 200,
154
+ headers: { "Content-Type": "text/html" },
155
+ body: "<html><body>Hello, World!</body></html>",
156
+ assert: async (res) => {
157
+ assert.equal(
158
+ await res.text(),
159
+ "<html><body>Hello, World!</body></html>"
160
+ );
161
+ },
162
+ },
163
+ {
164
+ name: "JSON response",
165
+ status: 200,
166
+ headers: { "Content-Type": "application/json" },
167
+ body: JSON.stringify({ message: "Hello, World!" }),
168
+ assert: async (res) => {
169
+ const body = await res.json();
170
+ assert.deepEqual(body, { message: "Hello, World!" });
171
+ },
172
+ },
173
+ {
174
+ name: "No Content response",
175
+ status: 204,
176
+ headers: {},
177
+ body: "",
178
+ assert: async (res) => {
179
+ assert.equal(await res.text(), "");
180
+ assert.equal(res.headers.get("Content-Length"), "0");
181
+ },
182
+ },
183
+ {
184
+ name: "Not Found response",
185
+ status: 404,
186
+ headers: { "Content-Type": "text/html" },
187
+ body: "Resource not found",
188
+ assert: async (res) => {
189
+ assert.equal(await res.text(), "Resource not found");
190
+ },
191
+ },
192
+ ];
193
+
194
+ for (const testCase of testCases) {
195
+ await t.test(`${testCase.name}`, async () => {
196
+ const response = await createResponse`HTTP/1.1 ${testCase.status} ${
197
+ testCase.status === 204 ? "No Content" : "OK"
198
+ }
199
+ Date: Mon, 27 Jul 2009 12:28:53 GMT
200
+ ${Object.entries(testCase.headers)
201
+ .map(([key, value]) => `${key}: ${value}`)
202
+ .join("\n")}
203
+
204
+ ${testCase.body}
205
+ `;
206
+ assert.equal(response.status, testCase.status);
207
+ for (const [key, value] of Object.entries(testCase.headers)) {
208
+ assert.equal(response.headers.get(key), value);
209
+ }
210
+ await testCase.assert(response);
211
+ });
212
+ }
213
+ });
214
+
215
+ await t.test("Response with various body types", async (t) => {
216
+ const testCases = [
217
+ {
218
+ name: "ArrayBuffer body",
219
+ body: new TextEncoder().encode("Hello, World!").buffer,
220
+ contentType: "application/octet-stream",
221
+ assert: async (res) => {
222
+ assert.equal(await res.text(), "Hello, World!");
223
+ },
224
+ },
225
+ {
226
+ name: "Blob body",
227
+ body: new Blob(["Hello, World!"], { type: "text/plain" }),
228
+ contentType: "text/plain",
229
+ assert: async (res) => {
230
+ assert.equal(await res.text(), "Hello, World!");
231
+ },
232
+ },
233
+ {
234
+ name: "ReadableStream body",
235
+ body: createReadableStream("This is streaming response data"),
236
+ contentType: "application/octet-stream",
237
+ assert: async (res) => {
238
+ assert.equal(await res.text(), "This is streaming response data");
239
+ },
240
+ },
241
+ ];
242
+
243
+ for (const testCase of testCases) {
244
+ await t.test(`Response with ${testCase.name}`, async () => {
245
+ const response = await createResponse`HTTP/1.1 200 OK
246
+ Date: Mon, 27 Jul 2009 12:28:53 GMT
247
+ Content-Type: ${testCase.contentType}
248
+
249
+ ${testCase.body}
250
+ `;
251
+ assert.equal(response.status, 200);
252
+ assert.equal(
253
+ response.headers.get("Content-Type"),
254
+ testCase.contentType
255
+ );
256
+ await testCase.assert(response);
257
+ });
258
+ }
259
+ });
260
+
261
+ await t.test("Response with custom headers", async () => {
262
+ const customHeaders = new Headers({
263
+ "X-Custom-Header": "CustomValue",
264
+ "Cache-Control": "no-cache",
265
+ });
266
+ const response = await createResponse`HTTP/1.1 200 OK
267
+ Date: Mon, 27 Jul 2009 12:28:53 GMT
268
+ ${customHeaders}
269
+
270
+ Custom response body
271
+ `;
272
+ assert.equal(response.status, 200);
273
+ assert.equal(response.headers.get("X-Custom-Header"), "CustomValue");
274
+ assert.equal(response.headers.get("Cache-Control"), "no-cache");
275
+ assert.equal(await response.text(), "Custom response body");
276
+ });
277
+
278
+ await t.test("Response with retroactive headers", async () => {
279
+ const response = await createResponse`HTTP/1.1 200 OK
280
+ Date: Mon, 27 Jul 2009 12:28:53 GMT
281
+
282
+ Hello, World!
283
+ `;
284
+ assert.equal(response.status, 200);
285
+ assert.equal(response.headers.get("Content-Type"), "text/html");
286
+ assert.equal(response.headers.get("Content-Length"), "13");
287
+ assert.equal(await response.text(), "Hello, World!");
288
+ });
289
+ });
package/test/route.mjs ADDED
@@ -0,0 +1,116 @@
1
+ import { createRoute } from "../create-route.mjs";
2
+
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert";
5
+
6
+ describe("createRoute", () => {
7
+ it("should create a basic route", async () => {
8
+ const route = createRoute()`Hello, World!`;
9
+ const response = await route(new Request("https://example.com"));
10
+ assert.strictEqual(await response.text(), "Hello, World!");
11
+ assert.strictEqual(response.status, 200);
12
+ assert.strictEqual(response.headers.get("Content-Type"), "text/html");
13
+ });
14
+
15
+ it("should handle function substitutions", async () => {
16
+ const route = createRoute()`The number is: ${() => 42}`;
17
+ const response = await route(new Request("https://example.com"));
18
+ assert.strictEqual(await response.text(), "The number is: 42");
19
+ });
20
+
21
+ it("should handle async function substitutions", async () => {
22
+ const route = createRoute()`The result is: ${async () => {
23
+ await new Promise((resolve) => setTimeout(resolve, 10));
24
+ return "async";
25
+ }}`;
26
+ const response = await route(new Request("https://example.com"));
27
+ assert.strictEqual(await response.text(), "The result is: async");
28
+ });
29
+
30
+ it("should allow setting custom headers", async () => {
31
+ const route = createRoute({
32
+ headers: { "X-Custom-Header": "Test" },
33
+ })`Custom header test`;
34
+ const response = await route(new Request("https://example.com"));
35
+ assert.strictEqual(response.headers.get("X-Custom-Header"), "Test");
36
+ });
37
+
38
+ it("should allow setting custom status", async () => {
39
+ const route = createRoute({
40
+ status: 404,
41
+ statusText: "Not Found",
42
+ })`404 Not Found`;
43
+ const response = await route(new Request("https://example.com"));
44
+ assert.strictEqual(response.status, 404);
45
+ assert.strictEqual(response.statusText, "Not Found");
46
+ });
47
+
48
+ it("should treat a null/undefined substitution as empty rather than throwing", async () => {
49
+ // Regression test: `sub.toString()` was called unconditionally on
50
+ // directly-substituted values, so `${null}` or `${undefined}` in a
51
+ // template threw "Cannot read properties of null/undefined (reading
52
+ // 'toString')" instead of behaving like ordinary JS template literals.
53
+ const nullRoute = createRoute()`Before ${null} After`;
54
+ const nullResponse = await nullRoute(new Request("https://example.com"));
55
+ assert.strictEqual(await nullResponse.text(), "Before After");
56
+
57
+ const undefinedRoute = createRoute()`Before ${undefined} After`;
58
+ const undefinedResponse = await undefinedRoute(
59
+ new Request("https://example.com")
60
+ );
61
+ assert.strictEqual(await undefinedResponse.text(), "Before After");
62
+ });
63
+
64
+ it("should treat a substitution function returning null the same as returning undefined", async () => {
65
+ // Regression test: a function substitution's `undefined` result is
66
+ // explicitly skipped, but a `null` result hit the same `.toString()`
67
+ // call and threw.
68
+ const route = createRoute()`Before ${() => null} After`;
69
+ const response = await route(new Request("https://example.com"));
70
+ assert.strictEqual(await response.text(), "Before After");
71
+ });
72
+
73
+ it("should pass through a stream/binary value returned by a substitution function", async () => {
74
+ // Regression test: a directly-substituted ReadableStream/Blob/
75
+ // ArrayBuffer/Uint8Array is returned as the raw response body, but a
76
+ // substitution *function* that returned one of those values was
77
+ // coerced with `.toString()`, producing the literal text
78
+ // "[object ReadableStream]" instead of the stream's actual content.
79
+ const route = createRoute()`${() =>
80
+ new ReadableStream({
81
+ start(controller) {
82
+ controller.enqueue(new TextEncoder().encode("STREAMED"));
83
+ controller.close();
84
+ },
85
+ })}`;
86
+ const response = await route(new Request("https://example.com"));
87
+ assert.strictEqual(await response.text(), "STREAMED");
88
+ });
89
+
90
+ it("should handle streaming responses", async () => {
91
+ const route = createRoute({ streaming: true })`
92
+ ${async (_, { response }) => {
93
+ response.headers.set("X-Streaming", "True");
94
+ await new Promise((resolve) => setTimeout(resolve, 10));
95
+ return "Part 1";
96
+ }}
97
+ ${async () => {
98
+ await new Promise((resolve) => setTimeout(resolve, 10));
99
+ return "Part 2";
100
+ }}
101
+ `;
102
+ const response = await route(new Request("https://example.com"));
103
+ assert.strictEqual(response.headers.get("X-Streaming"), "True");
104
+ assert.strictEqual(response.headers.get("Content-Length"), "13");
105
+
106
+ const reader = response.body.getReader();
107
+ let result = "";
108
+ while (true) {
109
+ const { done, value } = await reader.read();
110
+ if (done) break;
111
+ result += new TextDecoder().decode(value);
112
+ }
113
+ assert.match(result, /Part 1/);
114
+ assert.match(result, /Part 2/);
115
+ });
116
+ });
@@ -0,0 +1,213 @@
1
+ import { createRouter } from "../create-router.mjs";
2
+ import HTTPExpression, {
3
+ InlineParameter,
4
+ HeaderMatch,
5
+ } from "../utility/http-expression.mjs";
6
+
7
+ import { describe, it, test } from "node:test";
8
+ import assert from "node:assert";
9
+
10
+ test("Router - Basic routing", async () => {
11
+ const router = createRouter();
12
+ router.endpoint`GET /``Hello, World!`;
13
+ const request = new Request("http://example.com/");
14
+ const response = await router(request);
15
+ assert.equal(await response.text(), "Hello, World!");
16
+ assert.equal(response.status, 200);
17
+ });
18
+
19
+ test("Router - URL parameters", async () => {
20
+ const router = createRouter();
21
+ router.endpoint`GET /user/:id``User ID: ${(_, { params }) => params.id}`;
22
+
23
+ const request = new Request("http://example.com/user/123");
24
+ const response = await router(request);
25
+ assert.equal(await response.text(), "User ID: 123");
26
+ });
27
+
28
+ test("Router - 404 for unmatched routes", async () => {
29
+ const router = createRouter();
30
+ router.endpoint`GET /``Hello, World!`;
31
+
32
+ const request = new Request("http://example.com/not-found");
33
+ const response = await router(request);
34
+ assert.equal(response.status, 404);
35
+ });
36
+
37
+ test("Router - Custom error handler", async () => {
38
+ const router = createRouter({
39
+ errorHandler: (error, request) =>
40
+ new Response(`Custom Error: ${error.message}`, { status: 500 }),
41
+ });
42
+ router.endpoint`GET /error`(() => {
43
+ throw new Error("Test Error");
44
+ });
45
+
46
+ const request = new Request("http://example.com/error");
47
+ const response = await router(request);
48
+ assert.equal(response.status, 500);
49
+ assert.equal(await response.text(), "Custom Error: Test Error");
50
+ });
51
+
52
+ test("Router - Custom error handler catches rejections from async handlers", async () => {
53
+ // Regression test: an earlier version did `return handler(...)` inside the
54
+ // try block instead of `return await handler(...)`. Since calling an async
55
+ // function always returns a promise (even when it throws synchronously),
56
+ // that throw never surfaced inside the try/catch, so the rejection escaped
57
+ // past errorHandler entirely instead of producing a 500 response.
58
+ const router = createRouter({
59
+ errorHandler: (error, request) =>
60
+ new Response(`Custom Error: ${error.message}`, { status: 500 }),
61
+ });
62
+ router.endpoint`GET /async-error`(async () => {
63
+ throw new Error("Async Test Error");
64
+ });
65
+
66
+ const request = new Request("http://example.com/async-error");
67
+ const response = await router(request);
68
+ assert.equal(response.status, 500);
69
+ assert.equal(await response.text(), "Custom Error: Async Test Error");
70
+ });
71
+
72
+ test("Router - does not leak request headers into the response", async () => {
73
+ // Regression test: the handler wrapper created by `router.endpoint` used
74
+ // to pass the per-request context object (which includes `headers` set to
75
+ // the *request's* Headers, so substitution functions can read them) into
76
+ // createRoute() as its RouteInit. createRoute seeds the response
77
+ // Headers from `init.headers`, so every request header (Cookie,
78
+ // Authorization, arbitrary custom headers) was being echoed back as a
79
+ // response header.
80
+ const router = createRouter();
81
+ router.endpoint`GET /secure``Hello, World!`;
82
+
83
+ const request = new Request("http://example.com/secure", {
84
+ headers: {
85
+ Authorization: "Bearer topsecret",
86
+ Cookie: "sessionid=abc123",
87
+ "X-Secret-Session": "super-secret-token",
88
+ },
89
+ });
90
+ const response = await router(request);
91
+ assert.equal(response.headers.has("authorization"), false);
92
+ assert.equal(response.headers.has("cookie"), false);
93
+ assert.equal(response.headers.has("x-secret-session"), false);
94
+ assert.equal(await response.text(), "Hello, World!");
95
+ });
96
+
97
+ test("Router - substitution functions can still read request context after the header fix", async () => {
98
+ // Companion to the header-leak regression test above: fixing that bug must
99
+ // not remove handlers' ability to read params/method/headers via context.
100
+ const router = createRouter();
101
+ router.endpoint`GET /user/:id``ID:${(_, { params }) =>
102
+ params.id} Method:${(_, { method }) => method} Accept:${(
103
+ _,
104
+ { headers }
105
+ ) => headers.get("Accept")}`;
106
+
107
+ const request = new Request("http://example.com/user/42", {
108
+ headers: { Accept: "text/plain" },
109
+ });
110
+ const response = await router(request);
111
+ assert.equal(await response.text(), "ID:42 Method:GET Accept:text/plain");
112
+ });
113
+
114
+ test("Router - Multiple routes", async () => {
115
+ const router = createRouter();
116
+ router.endpoint`GET /``Home`;
117
+ router.endpoint`GET /about``About`;
118
+ router.endpoint`GET /contact``Contact`;
119
+
120
+ const routes = ["/", "/about", "/contact"];
121
+ for (const route of routes) {
122
+ const request = new Request(`http://example.com${route}`);
123
+ const response = await router(request);
124
+ assert.equal(
125
+ await response.text().then((s) => s.toLowerCase()),
126
+ route === "/" ? "home" : route.slice(1)
127
+ );
128
+ }
129
+ });
130
+
131
+ test("Router - Method matching", async () => {
132
+ const router = createRouter();
133
+ router.endpoint`GET /api``GET API`;
134
+ router.endpoint`POST /api``POST API`;
135
+
136
+ const getRequest = new Request("http://example.com/api", { method: "GET" });
137
+ const postRequest = new Request("http://example.com/api", { method: "POST" });
138
+
139
+ const getResponse = await router(getRequest);
140
+ const postResponse = await router(postRequest);
141
+
142
+ assert.equal(await getResponse.text(), "GET API");
143
+ assert.equal(await postResponse.text(), "POST API");
144
+ });
145
+
146
+ test("Router - Nested routes", async () => {
147
+ const router = createRouter();
148
+ router.endpoint`GET /api/v1/users``API v1 Users`;
149
+ router.endpoint`GET /api/v2/users``API v2 Users`;
150
+
151
+ const v1Request = new Request("http://example.com/api/v1/users");
152
+ const v2Request = new Request("http://example.com/api/v2/users");
153
+
154
+ const v1Response = await router(v1Request);
155
+ const v2Response = await router(v2Request);
156
+
157
+ assert.equal(await v1Response.text(), "API v1 Users");
158
+ assert.equal(await v2Response.text(), "API v2 Users");
159
+ });
160
+
161
+ test("Router - Function handler", async () => {
162
+ const router = createRouter();
163
+ router.endpoint`GET /function`((request) => {
164
+ return new Response("Function handler", { status: 200 });
165
+ });
166
+
167
+ const request = new Request("http://example.com/function");
168
+ const response = await router(request);
169
+
170
+ assert.equal(await response.text(), "Function handler");
171
+ assert.equal(response.status, 200);
172
+ });
173
+
174
+ // Commented out tests
175
+ /*
176
+ test("Router - InlineParam", async () => {
177
+ const router = createRouter();
178
+ const id = InlineParam({
179
+ name: "id",
180
+ type: "number",
181
+ min: 1,
182
+ max: 1000,
183
+ });
184
+ router.endpoint`GET /user/${id}``User ID: ${(_, { params }) => params.id}`;
185
+
186
+ const request = new Request("http://example.com/user/123");
187
+ const response = await router(request);
188
+ assert.equal(await response.text(), "User ID: 123");
189
+ });
190
+
191
+ test("Router - HeaderMatch", async () => {
192
+ const router = createRouter();
193
+ const jsonHeader = HeaderMatch({
194
+ name: "Content-Type",
195
+ value: "application/json",
196
+ });
197
+ router.endpoint`POST /api ${jsonHeader}`(async (request) => {
198
+ const data = await request.json();
199
+ return new Response(JSON.stringify({ received: data }), {
200
+ headers: { "Content-Type": "application/json" },
201
+ });
202
+ });
203
+
204
+ const request = new Request("http://example.com/api", {
205
+ method: "POST",
206
+ headers: { "Content-Type": "application/json" },
207
+ body: JSON.stringify({ test: "data" }),
208
+ });
209
+ const response = await router(request);
210
+ assert.equal(response.headers.get("Content-Type"), "application/json");
211
+ assert.equal(await response.json(), { received: { test: "data" } });
212
+ });
213
+ */
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "lib": [
5
+ "ESNext"
6
+ ]
7
+ },
8
+ "include": [
9
+ ".cushy/*.d.ts",
10
+ "**/*.ts"
11
+ ]
12
+ }