@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 CHANGED
@@ -100,7 +100,7 @@ Content-Type: application/json
100
100
 
101
101
  Letterpress provides several utility functions to help with request and response handling:
102
102
 
103
- > Note: `@johnhenry/letterpress` does not export a `serve` function or a `tagRequest` function. To run a server, pair it with [leserve](https://www.npmjs.com/package/@johnhenry/leserve) (imported directly, e.g. `import serve from "@johnhenry/leserve"`) or any server of your choice. To build a `Request` from a template literal, use `createRequest` (see below).
103
+ > Note: `@johnhenry/letterpress` does not export a `serve` function. To run a server, pair it with [leserve](https://www.npmjs.com/package/@johnhenry/leserve) (imported directly, e.g. `import serve from "@johnhenry/leserve"`) or any server of your choice. To build a `Request`/`Response` from a template literal, use `createRequest`/`createResponse` (forgiving) or `tagRequest`/`tagResponse` (strict -- see below for the difference).
104
104
 
105
105
  ### createRequest
106
106
 
@@ -130,6 +130,62 @@ Content-Type: application/json
130
130
  `;
131
131
  ```
132
132
 
133
+ ### tagRequest
134
+
135
+ A strict, from-scratch parser of raw HTTP-request text (see the README's
136
+ `createRequest`/`tagRequest` comparison table for the difference). The
137
+ request line is required. Substitutions are spliced in verbatim -- never
138
+ re-scanned for `"\n"`/`":"` -- so a substituted header value can safely
139
+ contain either. A binary substitution (`Blob`/`Uint8Array`/`ArrayBuffer`/
140
+ `ReadableStream`/`FormData`/`URLSearchParams`) becomes the real request
141
+ body, but must be the sole content of the body -- combining it with other
142
+ text throws.
143
+
144
+ ```javascript
145
+ import { tagRequest } from "@johnhenry/letterpress";
146
+
147
+ const request = await tagRequest`POST /users HTTP/1.1
148
+ Host: example.com
149
+ Content-Type: application/x-www-form-urlencoded
150
+ Content-Length: 49
151
+
152
+ name=FirstName+LastName&email=bsmth%40example.com`;
153
+
154
+ // Defaults for anything the template doesn't specify -- headers only.
155
+ // Method, URL, status, and body always come from the parsed template
156
+ // text, never from defaults.
157
+ const request2 = await tagRequest({ headers: { host: "example.com" } })`POST /users HTTP/1.1
158
+ Content-Type: application/x-www-form-urlencoded
159
+ Content-Length: 49
160
+
161
+ name=FirstName+LastName&email=bsmth%40example.com`;
162
+
163
+ // Sugar for the one common case -- throws if headers.host/Host is also set
164
+ const request3 = await tagRequest({ host: "example.com" })`POST /users HTTP/1.1
165
+ Content-Type: application/x-www-form-urlencoded
166
+ Content-Length: 49
167
+
168
+ name=FirstName+LastName&email=bsmth%40example.com`;
169
+ ```
170
+
171
+ ### tagResponse
172
+
173
+ The response-side counterpart to `tagRequest` -- itself a tagged-template
174
+ function (uncurried, like `createResponse`), requiring a status line.
175
+
176
+ ```javascript
177
+ import { tagResponse } from "@johnhenry/letterpress";
178
+
179
+ const response = await tagResponse`HTTP/1.1 201 Created
180
+ Content-Type: application/json
181
+ Location: http://example.com/users/123
182
+
183
+ {
184
+ "message": "New user created",
185
+ "user": { "id": 123, "firstName": "Example", "lastName": "Person", "email": "bsmth@example.com" }
186
+ }`;
187
+ ```
188
+
133
189
  ### HTTPExpression
134
190
 
135
191
  A tagged-template function for matching HTTP requests against a method/path/header pattern. It returns an object with `test(request)` and `exec(request)` methods — not `.method`/`.path`/`.version` properties.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,133 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
6
6
 
7
7
  This project will adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once it reaches 1.0.0.
8
8
 
9
+ ## [0.2.0] - 2026-09-30
10
+
11
+ ### Added
12
+
13
+ - ✨ **`tagRequest`/`tagResponse`** (#8): a strict, from-scratch pair of
14
+ tagged-template functions for building `Request`/`Response` objects
15
+ directly from raw HTTP-message text, coexisting with the existing
16
+ `createRequest`/`createResponse` rather than replacing them -- see the
17
+ README's new comparison table under `createRequest`, `createResponse`,
18
+ `tagRequest`, `tagResponse`. The request/status line is required (no
19
+ implicit default the way `createRoute`'s `init` object provides one);
20
+ `defaults` only ever contributes headers (plus a `host` sugar key and a
21
+ few `Request`-only fields raw text can't express) -- method, URL,
22
+ status, and body always come from the parsed template text. A binary
23
+ substitution (`Blob`/`Uint8Array`/`ArrayBuffer`/`ReadableStream`/
24
+ `FormData`/`URLSearchParams`) becomes the real body, but must be the
25
+ sole content of the body zone -- combining it with other text throws,
26
+ rather than silently corrupting or discarding either side.
27
+ - New shared internal parser, `utility/parse-http-text.mjs`: scans only
28
+ the template's literal `strings` pieces for structure (start line,
29
+ header lines, the blank line that ends them); a substitution's value is
30
+ spliced in verbatim and never re-scanned for `"\n"`/`":"`. This is the
31
+ single most important correctness property of the new parser -- see
32
+ below for how it also fixed two real, pre-existing bugs once
33
+ `create-route.mjs` was backported onto it.
34
+
35
+ ### Fixed
36
+
37
+ - 🐛 **`createRoute`/`createResponse`/`createRouter` silently overwrote a
38
+ repeated header name instead of accumulating it** -- `headers.set()`
39
+ was called for every header line found while parsing a template, so
40
+ e.g. two `Set-Cookie:` lines in a template (or an `init.headers` value
41
+ plus a same-named template header) resulted in only the last one
42
+ surviving. Now: the first occurrence of a header name overwrites
43
+ whatever was already set (from `init`/a previous occurrence), and a
44
+ second-or-later occurrence of the *same name within the template*
45
+ accumulates via `.append()` instead. **This is a real, user-visible
46
+ behavior change** for anyone relying on repeated headers being silently
47
+ collapsed to the last one -- if that was intentional, call
48
+ `headers.delete(name)` before setting it again via `context.setHeader`.
49
+ Fixed in the same change as the next item, both via `create-route.mjs`
50
+ now delegating its structural parsing to `utility/parse-http-text.mjs`
51
+ (function-valued substitutions are still resolved by `create-route.mjs`
52
+ itself in a pre-pass, preserving their side-effect/early-return
53
+ behavior exactly).
54
+ - 🐛 **A substituted value containing `"\n"` or `":"` could be
55
+ reinterpreted as new HTTP syntax** in `createRoute`/`createResponse`/
56
+ `createRouter` templates -- a stringified substitution was re-fed
57
+ through the same line tokenizer used for the template's own literal
58
+ text, so e.g. `` `X-Note: ${value}` `` could have `value` silently
59
+ start a new header line or end the header section early if it happened
60
+ to contain those characters. Regression tests added directly against
61
+ `createRoute` (not just via `tagRequest`/`tagResponse`) in
62
+ `test/route.mjs`.
63
+ - Removed dead code in `create-route.mjs`: a `JSON.parse`-based
64
+ Content-Type auto-sniff that could never actually run, since
65
+ `Content-Type` was always already set to `text/html` by the
66
+ unconditional check immediately before it. No behavior change --
67
+ `Content-Type` already always ended up `text/html` for a JSON-shaped
68
+ body with no explicit header, both before and after this cleanup.
69
+
70
+ ### Can I rely on the old header-overwrite behavior? No.
71
+
72
+ If any consumer depended on a repeated header name collapsing to the last
73
+ value written (rather than accumulating), that behavior is gone as of
74
+ this version. This was never documented as intentional -- it was an
75
+ unreviewed side effect of `headers.set()` being called unconditionally --
76
+ and the new behavior (both values survive, matching how real HTTP
77
+ Set-Cookie accumulation works and how `createRequest`'s own header parser
78
+ already behaved) is the one `create-route.mjs` was always meant to have.
79
+
80
+ ## [0.1.0] - 2026-09-28
81
+
82
+ ### Added
83
+
84
+ - ✨ **`createRewriter`**: `leroute` (the package letterpress was renamed
85
+ from -- see the README's provenance note) had a `createRewriter` export
86
+ (`leroute/rewrite`) for request/response rewriting: rewriting an inbound
87
+ request's target path/headers before it reaches a handler, and rewriting
88
+ a handler's response headers/status on the way back out. This never got
89
+ carried over during the leroute -> `@johnhenry/letterpress` rename --
90
+ it simply wasn't in the initial `@johnhenry/letterpress` source, a gap
91
+ in that migration rather than an intentional removal. It was discovered
92
+ while porting a downstream consumer, `prism` (a live HTTP request
93
+ inspector/proxy), whose proxy mode depends on it for rewriting proxied
94
+ requests/responses. Ported unchanged in behavior as `rewrite.mjs`
95
+ (`createRewriter`, plus `rewriteRequest`/`rewriteResponse`/`addRule`/
96
+ `removeRule`/`getRules`/`middleware`), exposed via a dedicated
97
+ `@johnhenry/letterpress/rewrite` subpath -- mirroring how
98
+ `createFSRouter` is isolated on `@johnhenry/letterpress/fs` rather than
99
+ the main barrel -- with its own `rewrite.d.ts` types wired into
100
+ `package.json`'s `exports` map and `tsconfig.json`. Tests ported from
101
+ leroute's `test/rewrite.mjs` into `test/rewrite.mjs`, plus new
102
+ `./rewrite` subpath-export regression tests in `test/exports.mjs` and
103
+ `test/types/fixture.ts` coverage, following the same pattern the `./fs`
104
+ subpath already established.
105
+
106
+ - ✨ **`router.mount(prefix, subHandler)`**: another `leroute` capability
107
+ (`create-lerouter.mjs`'s `router.mount`) dropped during the same rename,
108
+ found via the same `prism` port -- prism's own top-level router uses it
109
+ to mount its dashboard sub-router under `/inspect`. Delegates every
110
+ request under `prefix` to `subHandler` with the prefix stripped from the
111
+ forwarded request's path; `subHandler` may be a plain `(Request) =>
112
+ Response` function or an object exposing one as `.fetch`, so a `Router`
113
+ can mount another `Router`. Ported onto the main barrel (`create-router.mjs`,
114
+ right alongside `router.endpoint`, not an isolated subpath like `rewrite`
115
+ -- unlike `rewrite`/`fs`, `mount` is core routing behavior every router
116
+ needs, not an optional extra), dropping only the old implementation's
117
+ now-obsolete `middleware` and `request.raw` preservation (the current
118
+ `createRouter` doesn't have either concept). New `Mountable` type in
119
+ `types.d.ts`; seven new tests in `test/router.mjs` covering delegation,
120
+ the bare-prefix-with-no-trailing-segment case, 404 fallthrough for
121
+ non-matching paths, the `.fetch`-object form, query-string/method
122
+ preservation on the forwarded request, and ctx forwarding (below).
123
+
124
+ - ✨ **The router's own dispatch now accepts an optional second `ctx`
125
+ argument** (`router(request, ctx?)`), merged underneath `init` and the
126
+ matched route's own params so a route's params can't be shadowed by it,
127
+ and forwarded to `defaultHandler` too. This was also present in `leroute`
128
+ (`router(request, ctx = {})`) and dropped during the rename; brought back
129
+ specifically so `mount()` can hand a mounted sub-router its `mountPrefix`
130
+ (used by `prism` to reconstruct the full request path for display in its
131
+ captured-request feed, purely cosmetic but a real fidelity gap otherwise)
132
+ -- caught by testing `mount()` end-to-end against `prism` itself, not by
133
+ a direct comparison against the old source this time. Existing single-arg
134
+ `router(request)` callers are unaffected (`ctx` defaults to `{}`).
135
+
9
136
  ## [0.0.3] - 2026-09-26
10
137
 
11
138
  ### Fixed
package/README.md CHANGED
@@ -148,6 +148,23 @@ Creates a new router instance.
148
148
  - `errorHandler`: Custom error handler function (optional)
149
149
  - `cache`: Caching options (optional)
150
150
 
151
+ ### `router.mount(prefix: string, subHandler: Mountable): Router`
152
+
153
+ Delegates every request under `prefix` to `subHandler`, with the prefix
154
+ stripped from the forwarded request's path — e.g. `router.mount("/api",
155
+ apiRouter)` sends a request for `/api/users` to `apiRouter` as `/users`.
156
+ `subHandler` may be a plain `(Request) => Response` function or an object
157
+ exposing one as `.fetch` — a `Router` satisfies both, so one router can
158
+ mount another:
159
+
160
+ ```javascript
161
+ const api = createRouter();
162
+ api.endpoint`GET /users``[]`;
163
+
164
+ const router = createRouter();
165
+ router.mount("/api", api); // GET /api/users -> api's GET /users
166
+ ```
167
+
151
168
  ### `createRoute(options?: RouteInit): Route`
152
169
 
153
170
  Creates a new route handler.
@@ -171,9 +188,27 @@ expr.test(request); // boolean — does this request match?
171
188
  expr.exec(request); // matched params (plus method/headers), or null
172
189
  ```
173
190
 
174
- ### `createRequest`, `createResponse`
191
+ ### `createRequest`, `createResponse`, `tagRequest`, `tagResponse`
192
+
193
+ Four tagged-template functions for building `Request`/`Response` objects
194
+ from raw HTTP-message-shaped template literals -- two pairs with a
195
+ deliberately different purpose, not two ways to do the same thing:
196
+
197
+ | | `createRequest` / `createResponse` | `tagRequest` / `tagResponse` |
198
+ |---|---|---|
199
+ | Purpose | convenient, forgiving builder | strict raw-HTTP-text parser |
200
+ | Request/status line | optional / defaulted | **required** -- throws without one |
201
+ | Header value containing `": "` | truncated | parses correctly |
202
+ | Repeated header name (e.g. `Set-Cookie`) | both kept | both kept |
203
+ | Interpolated value containing `\n`/`:` | re-tokenized as syntax | spliced in verbatim, never re-scanned |
204
+ | Function-valued interpolation | not supported | not supported (use `createRoute` for that) |
205
+ | Binary substitution (`Blob`/stream/etc.) | becomes the body | becomes the body, but **must be the sole body content** -- throws if combined with other text |
175
206
 
176
- Tagged-template functions for building `Request`/`Response` objects from raw HTTP-message-shaped template literals. See [api.md](./api.md) for details.
207
+ Reach for `tagRequest`/`tagResponse` when a template literal needs to
208
+ behave exactly like the HTTP text it looks like (tests, fixtures,
209
+ hand-authored wire-format examples); reach for `createRequest`/
210
+ `createResponse` for everyday convenience where partial specification is
211
+ fine. See [api.md](./api.md) for the full examples of both.
177
212
 
178
213
  ### `createFSRouter`
179
214
 
@@ -189,7 +224,49 @@ import { createFSRouter } from "@johnhenry/letterpress/fs";
189
224
  > and the `theres-waldo` dependency, so it's kept out of the main entry
190
225
  > point to keep that entry point bundler-friendly for browser use. Everything
191
226
  > else (`createRouter`, `createRoute`, `createRequest`, `createResponse`,
192
- > `HTTPExpression`) is pure and safe to bundle for the browser.
227
+ > `tagRequest`, `tagResponse`, `HTTPExpression`) is pure and safe to bundle
228
+ > for the browser.
229
+
230
+ ### `createRewriter`
231
+
232
+ Builds a request/response rewriter for target-URL/path rewriting -- e.g.
233
+ rewriting an inbound path before it reaches a route, or rewriting a
234
+ proxied response's headers/status on the way back out. Useful for proxy
235
+ middleware (rewrite the target URL/path of a proxied request, adjust
236
+ headers, force a status code) as well as plain routing (redirect old
237
+ paths to new ones).
238
+
239
+ ```javascript
240
+ import { createRewriter } from "@johnhenry/letterpress/rewrite";
241
+
242
+ const rewriter = createRewriter([
243
+ {
244
+ match: { path: /^\/api\/v1\/(.*)/ },
245
+ action: { rewritePath: "/api/v2/$1" },
246
+ },
247
+ ]);
248
+
249
+ // Wrap a handler: the handler receives the rewritten request, and its
250
+ // response is run back through the rewriter before being returned.
251
+ const handler = rewriter.middleware(async (request) => {
252
+ const url = new URL(request.url);
253
+ return new Response(`routed to ${url.pathname}`);
254
+ });
255
+
256
+ // Or call rewriteRequest/rewriteResponse directly, and manage rules with
257
+ // addRule/removeRule/getRules.
258
+ rewriter.addRule({ match: { path: "/teapot" }, action: { setStatus: 418 } });
259
+ ```
260
+
261
+ > `createRewriter` is imported from the `@johnhenry/letterpress/rewrite`
262
+ > subpath, not the main `@johnhenry/letterpress` barrel, mirroring how
263
+ > `createFSRouter` is isolated on `@johnhenry/letterpress/fs` above.
264
+ >
265
+ > This existed in `leroute` (the package letterpress was renamed from --
266
+ > see the provenance note near the top of this README) but was dropped
267
+ > during that rename. It's ported here, discovered missing while porting a
268
+ > downstream consumer (`prism`, a request inspector/proxy) that relies on
269
+ > it for proxy-mode rewriting -- see CHANGELOG.md.
193
270
 
194
271
  ### `deconstruct`, `cook`
195
272
 
@@ -241,6 +318,13 @@ is designed to pair with a sibling package that does.
241
318
  `Route`-shaped function works too; `leserve` is the tested, documented
242
319
  pairing, not a hard dependency (it's a `devDependency` here, used only in
243
320
  the demo scripts).
321
+ - **[`@johnhenry/prism`](https://github.com/johnhenry/prism)** -- a live
322
+ HTTP request inspector/proxy that uses `createRouter()` as its top-level
323
+ router. Porting it surfaced two real gaps left over from letterpress's
324
+ own rename from `leroute`: `router.mount()` (with the `ctx`-forwarding
325
+ its `mountPrefix` depends on) and `createRewriter`
326
+ (`@johnhenry/letterpress/rewrite`) -- both closed as part of that port,
327
+ see CHANGELOG.md.
244
328
 
245
329
  ## 🤝 Contributing
246
330
 
package/create-route.mjs CHANGED
@@ -1,5 +1,25 @@
1
+ import { parseHttpText } from "./utility/parse-http-text.mjs";
2
+
1
3
  const DEFAULT_REQUEST = () => new Request("http://.");
2
4
 
5
+ // Same rule tag-request.mjs/tag-response.mjs use: the first occurrence of
6
+ // a header name overwrites whatever was already set (e.g. from `init`),
7
+ // a second-or-later occurrence of the *same name* accumulates instead of
8
+ // overwriting it. Fixes the Set-Cookie-overwrite bug this file used to
9
+ // have (headers.set() for every line, unconditionally).
10
+ const mergeHeaderEntries = (headers, headerEntries) => {
11
+ const seen = new Set();
12
+ for (const { name, value } of headerEntries) {
13
+ const key = name.toLowerCase();
14
+ if (seen.has(key)) {
15
+ headers.append(name, value);
16
+ } else {
17
+ headers.set(name, value);
18
+ seen.add(key);
19
+ }
20
+ }
21
+ };
22
+
3
23
  export const createRoute = (initOrMiddleware) => {
4
24
  const getInit =
5
25
  typeof initOrMiddleware === "function"
@@ -29,140 +49,57 @@ export const createRoute = (initOrMiddleware) => {
29
49
  ...additionalContext,
30
50
  };
31
51
 
32
- const processInput = async () => {
33
- let mode = "INITIAL";
34
- let buffer = "";
35
- let bodyContent = "";
36
- let headerName = "";
37
- let i = 0;
38
- let j = 0;
39
-
40
- const processChunk = (chunk) => {
41
- buffer += chunk;
42
- const lines = buffer.split("\n");
43
-
44
- while (lines.length > 1) {
45
- const line = lines.shift().trim();
46
-
47
- switch (mode) {
48
- case "INITIAL":
49
- if (line.startsWith("HTTP/")) {
50
- const [httpVersion, statusCode, ...statusTextParts] =
51
- line.split(" ");
52
- status = parseInt(statusCode, 10);
53
- statusText = statusTextParts.join(" ");
54
- mode = "HEADER";
55
- } else if (line !== "") {
56
- mode = "BODY";
57
- bodyContent += line + "\n";
58
- }
59
- break;
60
-
61
- case "HEADER":
62
- if (line === "") {
63
- mode = "BODY";
64
- } else {
65
- const colonIndex = line.indexOf(":");
66
- if (colonIndex > 0) {
67
- const key = line.slice(0, colonIndex).trim();
68
- const value = line.slice(colonIndex + 1).trim();
69
- headers.set(key, value);
70
- }
71
- }
72
- break;
73
-
74
- case "BODY":
75
- bodyContent += line + "\n";
76
- break;
77
- }
78
- }
79
-
80
- buffer = lines[0] || "";
81
- };
82
-
83
- while (i < strings.length) {
84
- processChunk(strings[i]);
85
-
86
- if (j < substitutions.length) {
87
- const sub = substitutions[j];
88
- if (typeof sub === "function") {
89
- const result = await sub(request, context);
90
- if (
91
- result instanceof ReadableStream ||
92
- result instanceof Blob ||
93
- result instanceof ArrayBuffer ||
94
- result instanceof Uint8Array
95
- ) {
96
- // A substitution function can return a stream/binary value
97
- // just like a directly-substituted one; it must get the
98
- // same raw-passthrough treatment rather than being coerced
99
- // via .toString() (which would produce "[object ...]").
100
- if (!headers.has("Content-Type")) {
101
- headers.set(
102
- "Content-Type",
103
- result.type ?? "application/octet-stream" // Blob may have a type
104
- );
105
- }
106
- return result;
107
- } else if (result !== undefined && result !== null) {
108
- processChunk(result.toString());
109
- }
110
- } else if (
111
- sub instanceof ReadableStream ||
112
- sub instanceof Blob ||
113
- sub instanceof ArrayBuffer ||
114
- sub instanceof Uint8Array
115
- ) {
116
- if (!headers.has("Content-Type")) {
117
- headers.set(
118
- "Content-Type",
119
- sub.type ?? "application/octet-stream" // Blob may have a type
120
- );
121
- }
122
- return sub;
123
- } else if (sub instanceof Headers) {
124
- for (const [key, value] of sub) {
125
- headers.set(key, value);
126
- }
127
- } else if (sub !== undefined && sub !== null) {
128
- processChunk(sub.toString());
129
- }
130
- j++;
131
- }
52
+ // Resolve every function-valued substitution up front -- this is
53
+ // create-route's one feature the shared parser doesn't (and
54
+ // shouldn't) know about: a substitution can run arbitrary async
55
+ // work and mutate `context` (headers/status) as a side effect, in
56
+ // addition to contributing a value to the template. `null`/
57
+ // `undefined` are preserved (parseHttpText already treats either as
58
+ // "contributes nothing," matching ordinary JS template semantics).
59
+ const resolvedSubstitutions = [];
60
+ for (const sub of substitutions) {
61
+ resolvedSubstitutions.push(
62
+ typeof sub === "function" ? await sub(request, context) : sub
63
+ );
64
+ }
132
65
 
133
- i++;
66
+ const { startLine, headerEntries, body } = parseHttpText(
67
+ strings,
68
+ resolvedSubstitutions,
69
+ {
70
+ isStartLine: (line) => line.startsWith("HTTP/"),
71
+ trimBodyLines: true,
134
72
  }
73
+ );
135
74
 
136
- // Process any remaining buffer content
137
- if (buffer) {
138
- processChunk("\n");
75
+ if (startLine !== null) {
76
+ const [, statusCodeText, ...statusTextParts] = startLine.split(" ");
77
+ const parsedStatus = parseInt(statusCodeText, 10);
78
+ if (!Number.isNaN(parsedStatus)) {
79
+ status = parsedStatus;
80
+ statusText = statusTextParts.join(" ");
139
81
  }
140
-
141
- return bodyContent.trim();
142
- };
143
-
144
- let responseBody = await processInput();
145
-
146
- if (!headers.has("Content-Type")) {
147
- headers.set("Content-Type", "text/html");
148
82
  }
149
83
 
150
- if (typeof responseBody === "string") {
151
- try {
152
- JSON.parse(responseBody);
153
- if (!headers.has("Content-Type")) {
154
- headers.set("Content-Type", "application/json");
155
- }
156
- } catch {
157
- // Not JSON, keep existing Content-Type
158
- }
84
+ mergeHeaderEntries(headers, headerEntries);
159
85
 
160
- if (!headers.has("Content-Length")) {
86
+ let responseBody = body;
87
+ const isBinaryBody = typeof responseBody !== "string";
88
+
89
+ if (isBinaryBody) {
90
+ if (!headers.has("Content-Type")) {
161
91
  headers.set(
162
- "Content-Length",
163
- new Blob([responseBody]).size.toString()
92
+ "Content-Type",
93
+ responseBody.type ?? "application/octet-stream" // Blob may have a type
164
94
  );
165
95
  }
96
+ } else {
97
+ if (!headers.has("Content-Type")) {
98
+ headers.set("Content-Type", "text/html");
99
+ }
100
+ if (!headers.has("Content-Length")) {
101
+ headers.set("Content-Length", new Blob([responseBody]).size.toString());
102
+ }
166
103
  }
167
104
 
168
105
  if (streaming && typeof responseBody === "string") {
@@ -173,11 +110,11 @@ export const createRoute = (initOrMiddleware) => {
173
110
  },
174
111
  });
175
112
  }
113
+
176
114
  return new Response(
177
115
  status === 204 && !responseBody ? null : responseBody, // If status is 204, body must be null
178
116
  { headers, status, statusText }
179
117
  );
180
- // return new Response(responseBody, { headers, status, statusText });
181
118
  };
182
119
  };
183
120
  };
package/create-router.mjs CHANGED
@@ -11,7 +11,12 @@ export const createRouter = (initial = {}) => {
11
11
  ...init
12
12
  } = initial;
13
13
 
14
- const router = async (request) => {
14
+ // `ctx` is an optional caller-supplied context object (e.g. `.mount()`
15
+ // uses it to pass `mountPrefix` down to a mounted sub-router) merged
16
+ // underneath the router's own `init` and the matched route's own params --
17
+ // so a route's own params can't be shadowed by a mount's ctx, but a
18
+ // handler can still read whatever the caller passed in.
19
+ const router = async (request, ctx = {}) => {
15
20
  try {
16
21
  for (const [matcher, handler] of routes) {
17
22
  const match = matcher(request);
@@ -20,10 +25,10 @@ export const createRouter = (initial = {}) => {
20
25
  // directly) is required so that a handler that throws inside an
21
26
  // async function (i.e. rejects its returned promise) is still
22
27
  // caught below and routed to errorHandler.
23
- return await handler(request, { ...init, ...match });
28
+ return await handler(request, { ...ctx, ...init, ...match });
24
29
  }
25
30
  }
26
- return await defaultHandler(request);
31
+ return await defaultHandler(request, ctx);
27
32
  } catch (error) {
28
33
  return errorHandler(error, request);
29
34
  }
@@ -57,5 +62,47 @@ export const createRouter = (initial = {}) => {
57
62
  return router;
58
63
  };
59
64
  };
65
+
66
+ // Delegate every request under `prefix` to `subHandler`, with the prefix
67
+ // stripped from the forwarded request's path -- e.g. `router.mount("/api",
68
+ // apiRouter)` sends a request for `/api/users` to `apiRouter` as `/users`.
69
+ // `subHandler` may be a plain `(Request) => Response` function or an
70
+ // object exposing one as `.fetch` (matching how `Router` itself works, so
71
+ // one router can mount another).
72
+ router.mount = (prefix, subHandler) => {
73
+ const normalizedPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
74
+
75
+ const matcher = (request) => {
76
+ const url = new URL(request.url);
77
+ if (
78
+ url.pathname === normalizedPrefix ||
79
+ url.pathname.startsWith(normalizedPrefix + "/")
80
+ ) {
81
+ return { params: {}, method: request.method, headers: {} };
82
+ }
83
+ return null;
84
+ };
85
+
86
+ const handler = (request) => {
87
+ const url = new URL(request.url);
88
+ const newPath = url.pathname.slice(normalizedPrefix.length) || "/";
89
+ const newUrl = new URL(newPath + url.search + url.hash, url.origin);
90
+
91
+ const hasBody = request.method !== "GET" && request.method !== "HEAD";
92
+ const newRequest = new Request(newUrl.toString(), {
93
+ method: request.method,
94
+ headers: request.headers,
95
+ body: hasBody ? request.body : undefined,
96
+ duplex: hasBody ? "half" : undefined,
97
+ });
98
+
99
+ const sub = typeof subHandler === "function" ? subHandler : subHandler.fetch;
100
+ return sub(newRequest, { mountPrefix: normalizedPrefix });
101
+ };
102
+
103
+ routes.push([matcher, handler]);
104
+ return router;
105
+ };
106
+
60
107
  return router;
61
108
  };
package/index.mjs CHANGED
@@ -2,6 +2,8 @@ export { createRequest } from "./create-request.mjs";
2
2
  export { createRouter } from "./create-router.mjs";
3
3
  export { createRoute } from "./create-route.mjs";
4
4
  export { createResponse } from "./create-response.mjs";
5
+ export { tagRequest } from "./tag-request.mjs";
6
+ export { tagResponse } from "./tag-response.mjs";
5
7
  export * from "./utility/index.mjs";
6
8
  export { deconstruct } from "./utility/deconstruct.mjs";
7
9
  export { cook } from "./utility/cook.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/letterpress",
3
- "version": "0.0.3",
3
+ "version": "0.2.0",
4
4
  "description": "A tagged-template-string HTTP routing library for defining and matching request handlers with plain JavaScript syntax.",
5
5
  "main": "index.mjs",
6
6
  "types": "types.d.ts",
@@ -13,6 +13,10 @@
13
13
  "types": "./fs.d.ts",
14
14
  "default": "./fs-router.mjs"
15
15
  },
16
+ "./rewrite": {
17
+ "types": "./rewrite.d.ts",
18
+ "default": "./rewrite.mjs"
19
+ },
16
20
  "./package.json": "./package.json",
17
21
  "./*": "./*"
18
22
  },
@@ -58,4 +62,4 @@
58
62
  "dependencies": {
59
63
  "theres-waldo": "^0.0.0"
60
64
  }
61
- }
65
+ }