@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/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
+ });
@@ -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
@@ -15,6 +15,7 @@
15
15
  "include": [
16
16
  "types.d.ts",
17
17
  "fs.d.ts",
18
+ "rewrite.d.ts",
18
19
  "test/types/**/*.ts"
19
20
  ]
20
21
  }
package/types.d.ts CHANGED
@@ -19,12 +19,18 @@ export type RouterEndpointResponder = {
19
19
  (handler: Route): Router;
20
20
  };
21
21
 
22
+ // A handler mountable under a Router prefix: a plain (Request) => Response
23
+ // function, or an object exposing one as `.fetch` (so a Router can mount
24
+ // another Router).
25
+ export type Mountable = Route | { fetch: Route };
26
+
22
27
  // Extension for the Router to add endpoints
23
28
  export type RouterExtension = {
24
29
  endpoint: (
25
30
  template: TemplateStringsArray,
26
31
  ...substitutions: any[]
27
32
  ) => RouterEndpointResponder;
33
+ mount: (prefix: string, subHandler: Mountable) => Router;
28
34
  };
29
35
 
30
36
  // Combines Route and RouterExtension
@@ -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;