@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/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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
+
"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
|
+
}
|