@johnhenry/letterpress 0.1.0 → 0.2.1
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 +99 -0
- package/README.md +23 -4
- package/create-route.mjs +55 -126
- package/index.mjs +2 -0
- package/package.json +1 -1
- package/tag-request.mjs +100 -0
- package/tag-response.mjs +73 -0
- package/test/index.mjs +1 -0
- package/test/route.mjs +58 -0
- package/test/tag-req-res.mjs +253 -0
- package/test/types/fixture.ts +20 -0
- package/types.d.ts +38 -0
- package/utility/parse-http-text.mjs +186 -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,105 @@ 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.1] - 2026-10-01
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- 🐛 **A later function substitution's `context.setHeader()` stopped
|
|
14
|
+
overwriting an earlier template header line**, reversing `0.2.0`'s own
|
|
15
|
+
create-route.mjs backport. That backport resolved every function-valued
|
|
16
|
+
substitution in a pre-pass, before any template header line was
|
|
17
|
+
applied -- so a template header line always won, regardless of whether
|
|
18
|
+
a function substitution calling `setHeader` for the same name appeared
|
|
19
|
+
*after* it (originally, whichever ran later always won, matching plain
|
|
20
|
+
left-to-right template evaluation). `utility/parse-http-text.mjs` now
|
|
21
|
+
takes a `resolveSubstitution` callback invoked at the exact point the
|
|
22
|
+
scan reaches each substitution, and an `onHeaderLine` callback invoked
|
|
23
|
+
immediately per header line, instead of batching either until the whole
|
|
24
|
+
parse finishes -- restoring the original ordering while still sharing
|
|
25
|
+
one parser implementation. `tagRequest`/`tagResponse` are unaffected
|
|
26
|
+
(they have no function-substitution/side-effect concept to reorder).
|
|
27
|
+
Found by deliberately re-testing `0.2.0`'s backport against the
|
|
28
|
+
pre-refactor code's actual behavior, not by inspection.
|
|
29
|
+
- A function-valued substitution returning a `Headers` object is now
|
|
30
|
+
merged directly, like a directly-substituted `Headers` object already
|
|
31
|
+
was -- previously it fell through to `.toString()`, producing the
|
|
32
|
+
literal text `"[object Headers]"` in the response body and silently
|
|
33
|
+
dropping the headers. Not separately tested before (no prior test
|
|
34
|
+
covered this combination); noted here since it's a user-visible
|
|
35
|
+
behavior difference from `0.2.0` and earlier, not just a `0.2.0` revert.
|
|
36
|
+
|
|
37
|
+
## [0.2.0] - 2026-09-30
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- ✨ **`tagRequest`/`tagResponse`** (#8): a strict, from-scratch pair of
|
|
42
|
+
tagged-template functions for building `Request`/`Response` objects
|
|
43
|
+
directly from raw HTTP-message text, coexisting with the existing
|
|
44
|
+
`createRequest`/`createResponse` rather than replacing them -- see the
|
|
45
|
+
README's new comparison table under `createRequest`, `createResponse`,
|
|
46
|
+
`tagRequest`, `tagResponse`. The request/status line is required (no
|
|
47
|
+
implicit default the way `createRoute`'s `init` object provides one);
|
|
48
|
+
`defaults` only ever contributes headers (plus a `host` sugar key and a
|
|
49
|
+
few `Request`-only fields raw text can't express) -- method, URL,
|
|
50
|
+
status, and body always come from the parsed template text. A binary
|
|
51
|
+
substitution (`Blob`/`Uint8Array`/`ArrayBuffer`/`ReadableStream`/
|
|
52
|
+
`FormData`/`URLSearchParams`) becomes the real body, but must be the
|
|
53
|
+
sole content of the body zone -- combining it with other text throws,
|
|
54
|
+
rather than silently corrupting or discarding either side.
|
|
55
|
+
- New shared internal parser, `utility/parse-http-text.mjs`: scans only
|
|
56
|
+
the template's literal `strings` pieces for structure (start line,
|
|
57
|
+
header lines, the blank line that ends them); a substitution's value is
|
|
58
|
+
spliced in verbatim and never re-scanned for `"\n"`/`":"`. This is the
|
|
59
|
+
single most important correctness property of the new parser -- see
|
|
60
|
+
below for how it also fixed two real, pre-existing bugs once
|
|
61
|
+
`create-route.mjs` was backported onto it.
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
|
|
65
|
+
- 🐛 **`createRoute`/`createResponse`/`createRouter` silently overwrote a
|
|
66
|
+
repeated header name instead of accumulating it** -- `headers.set()`
|
|
67
|
+
was called for every header line found while parsing a template, so
|
|
68
|
+
e.g. two `Set-Cookie:` lines in a template (or an `init.headers` value
|
|
69
|
+
plus a same-named template header) resulted in only the last one
|
|
70
|
+
surviving. Now: the first occurrence of a header name overwrites
|
|
71
|
+
whatever was already set (from `init`/a previous occurrence), and a
|
|
72
|
+
second-or-later occurrence of the *same name within the template*
|
|
73
|
+
accumulates via `.append()` instead. **This is a real, user-visible
|
|
74
|
+
behavior change** for anyone relying on repeated headers being silently
|
|
75
|
+
collapsed to the last one -- if that was intentional, call
|
|
76
|
+
`headers.delete(name)` before setting it again via `context.setHeader`.
|
|
77
|
+
Fixed in the same change as the next item, both via `create-route.mjs`
|
|
78
|
+
now delegating its structural parsing to `utility/parse-http-text.mjs`
|
|
79
|
+
(function-valued substitutions are still resolved by `create-route.mjs`
|
|
80
|
+
itself in a pre-pass, preserving their side-effect/early-return
|
|
81
|
+
behavior exactly).
|
|
82
|
+
- 🐛 **A substituted value containing `"\n"` or `":"` could be
|
|
83
|
+
reinterpreted as new HTTP syntax** in `createRoute`/`createResponse`/
|
|
84
|
+
`createRouter` templates -- a stringified substitution was re-fed
|
|
85
|
+
through the same line tokenizer used for the template's own literal
|
|
86
|
+
text, so e.g. `` `X-Note: ${value}` `` could have `value` silently
|
|
87
|
+
start a new header line or end the header section early if it happened
|
|
88
|
+
to contain those characters. Regression tests added directly against
|
|
89
|
+
`createRoute` (not just via `tagRequest`/`tagResponse`) in
|
|
90
|
+
`test/route.mjs`.
|
|
91
|
+
- Removed dead code in `create-route.mjs`: a `JSON.parse`-based
|
|
92
|
+
Content-Type auto-sniff that could never actually run, since
|
|
93
|
+
`Content-Type` was always already set to `text/html` by the
|
|
94
|
+
unconditional check immediately before it. No behavior change --
|
|
95
|
+
`Content-Type` already always ended up `text/html` for a JSON-shaped
|
|
96
|
+
body with no explicit header, both before and after this cleanup.
|
|
97
|
+
|
|
98
|
+
### Can I rely on the old header-overwrite behavior? No.
|
|
99
|
+
|
|
100
|
+
If any consumer depended on a repeated header name collapsing to the last
|
|
101
|
+
value written (rather than accumulating), that behavior is gone as of
|
|
102
|
+
this version. This was never documented as intentional -- it was an
|
|
103
|
+
unreviewed side effect of `headers.set()` being called unconditionally --
|
|
104
|
+
and the new behavior (both values survive, matching how real HTTP
|
|
105
|
+
Set-Cookie accumulation works and how `createRequest`'s own header parser
|
|
106
|
+
already behaved) is the one `create-route.mjs` was always meant to have.
|
|
107
|
+
|
|
9
108
|
## [0.1.0] - 2026-09-28
|
|
10
109
|
|
|
11
110
|
### Added
|
package/README.md
CHANGED
|
@@ -188,9 +188,27 @@ expr.test(request); // boolean — does this request match?
|
|
|
188
188
|
expr.exec(request); // matched params (plus method/headers), or null
|
|
189
189
|
```
|
|
190
190
|
|
|
191
|
-
### `createRequest`, `createResponse`
|
|
192
|
-
|
|
193
|
-
|
|
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 |
|
|
206
|
+
|
|
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.
|
|
194
212
|
|
|
195
213
|
### `createFSRouter`
|
|
196
214
|
|
|
@@ -206,7 +224,8 @@ import { createFSRouter } from "@johnhenry/letterpress/fs";
|
|
|
206
224
|
> and the `theres-waldo` dependency, so it's kept out of the main entry
|
|
207
225
|
> point to keep that entry point bundler-friendly for browser use. Everything
|
|
208
226
|
> else (`createRouter`, `createRoute`, `createRequest`, `createResponse`,
|
|
209
|
-
> `HTTPExpression`) is pure and safe to bundle
|
|
227
|
+
> `tagRequest`, `tagResponse`, `HTTPExpression`) is pure and safe to bundle
|
|
228
|
+
> for the browser.
|
|
210
229
|
|
|
211
230
|
### `createRewriter`
|
|
212
231
|
|
package/create-route.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { parseHttpText } from "./utility/parse-http-text.mjs";
|
|
2
|
+
|
|
1
3
|
const DEFAULT_REQUEST = () => new Request("http://.");
|
|
2
4
|
|
|
3
5
|
export const createRoute = (initOrMiddleware) => {
|
|
@@ -29,140 +31,67 @@ export const createRoute = (initOrMiddleware) => {
|
|
|
29
31
|
...additionalContext,
|
|
30
32
|
};
|
|
31
33
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
}
|
|
132
|
-
|
|
133
|
-
i++;
|
|
34
|
+
// Same rule tag-request.mjs/tag-response.mjs use: the first
|
|
35
|
+
// occurrence of a header name overwrites whatever was already set
|
|
36
|
+
// (e.g. from `init`), a second-or-later occurrence of the *same
|
|
37
|
+
// name* accumulates instead of overwriting it. Applied immediately
|
|
38
|
+
// per header line (via onHeaderLine below), not batched until the
|
|
39
|
+
// whole parse finishes -- a *later* function substitution's
|
|
40
|
+
// context.setHeader() call must still be able to overwrite an
|
|
41
|
+
// earlier template header line, exactly like the original
|
|
42
|
+
// chunk-by-chunk implementation did.
|
|
43
|
+
const seenHeaderNames = new Set();
|
|
44
|
+
const applyHeaderLine = (name, value) => {
|
|
45
|
+
const key = name.toLowerCase();
|
|
46
|
+
if (seenHeaderNames.has(key)) {
|
|
47
|
+
headers.append(name, value);
|
|
48
|
+
} else {
|
|
49
|
+
headers.set(name, value);
|
|
50
|
+
seenHeaderNames.add(key);
|
|
134
51
|
}
|
|
135
|
-
|
|
136
|
-
// Process any remaining buffer content
|
|
137
|
-
if (buffer) {
|
|
138
|
-
processChunk("\n");
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
return bodyContent.trim();
|
|
142
52
|
};
|
|
143
53
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
54
|
+
const { startLine, body } = await parseHttpText(strings, substitutions, {
|
|
55
|
+
isStartLine: (line) => line.startsWith("HTTP/"),
|
|
56
|
+
trimBodyLines: true,
|
|
57
|
+
onHeaderLine: applyHeaderLine,
|
|
58
|
+
// Function-valued substitutions are create-route's one feature
|
|
59
|
+
// the shared parser doesn't (and shouldn't) know about on its
|
|
60
|
+
// own: a substitution can run arbitrary async work and mutate
|
|
61
|
+
// `context` (headers/status) as a side effect, in addition to
|
|
62
|
+
// contributing a value to the template. Resolved inline, at the
|
|
63
|
+
// exact point the scan reaches it, so its side effects land in
|
|
64
|
+
// the same left-to-right order as the template text itself.
|
|
65
|
+
resolveSubstitution: (sub) =>
|
|
66
|
+
typeof sub === "function" ? sub(request, context) : sub,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (startLine !== null) {
|
|
70
|
+
const [, statusCodeText, ...statusTextParts] = startLine.split(" ");
|
|
71
|
+
const parsedStatus = parseInt(statusCodeText, 10);
|
|
72
|
+
if (!Number.isNaN(parsedStatus)) {
|
|
73
|
+
status = parsedStatus;
|
|
74
|
+
statusText = statusTextParts.join(" ");
|
|
75
|
+
}
|
|
148
76
|
}
|
|
149
77
|
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
}
|
|
78
|
+
let responseBody = body;
|
|
79
|
+
const isBinaryBody = typeof responseBody !== "string";
|
|
159
80
|
|
|
160
|
-
|
|
81
|
+
if (isBinaryBody) {
|
|
82
|
+
if (!headers.has("Content-Type")) {
|
|
161
83
|
headers.set(
|
|
162
|
-
"Content-
|
|
163
|
-
|
|
84
|
+
"Content-Type",
|
|
85
|
+
responseBody.type ?? "application/octet-stream" // Blob may have a type
|
|
164
86
|
);
|
|
165
87
|
}
|
|
88
|
+
} else {
|
|
89
|
+
if (!headers.has("Content-Type")) {
|
|
90
|
+
headers.set("Content-Type", "text/html");
|
|
91
|
+
}
|
|
92
|
+
if (!headers.has("Content-Length")) {
|
|
93
|
+
headers.set("Content-Length", new Blob([responseBody]).size.toString());
|
|
94
|
+
}
|
|
166
95
|
}
|
|
167
96
|
|
|
168
97
|
if (streaming && typeof responseBody === "string") {
|
|
@@ -173,11 +102,11 @@ export const createRoute = (initOrMiddleware) => {
|
|
|
173
102
|
},
|
|
174
103
|
});
|
|
175
104
|
}
|
|
105
|
+
|
|
176
106
|
return new Response(
|
|
177
107
|
status === 204 && !responseBody ? null : responseBody, // If status is 204, body must be null
|
|
178
108
|
{ headers, status, statusText }
|
|
179
109
|
);
|
|
180
|
-
// return new Response(responseBody, { headers, status, statusText });
|
|
181
110
|
};
|
|
182
111
|
};
|
|
183
112
|
};
|
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.1
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|
package/tag-request.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { parseHttpText } from "./utility/parse-http-text.mjs";
|
|
2
|
+
|
|
3
|
+
const inferBodyContentType = (body) => {
|
|
4
|
+
if (body instanceof ReadableStream) return "application/octet-stream";
|
|
5
|
+
if (body instanceof Blob) return body.type || "application/octet-stream";
|
|
6
|
+
if (body instanceof URLSearchParams)
|
|
7
|
+
return "application/x-www-form-urlencoded";
|
|
8
|
+
if (body instanceof ArrayBuffer || body instanceof Uint8Array)
|
|
9
|
+
return "application/octet-stream";
|
|
10
|
+
return null; // FormData sets its own; plain strings get no guessed type
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const mergeHeaderEntries = (baseHeaders, headerEntries) => {
|
|
14
|
+
const seen = new Set();
|
|
15
|
+
for (const { name, value } of headerEntries) {
|
|
16
|
+
const key = name.toLowerCase();
|
|
17
|
+
if (seen.has(key)) {
|
|
18
|
+
baseHeaders.append(name, value);
|
|
19
|
+
} else {
|
|
20
|
+
baseHeaders.set(name, value);
|
|
21
|
+
seen.add(key);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return baseHeaders;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const PASSTHROUGH_REQUEST_INIT_KEYS = [
|
|
28
|
+
"redirect",
|
|
29
|
+
"credentials",
|
|
30
|
+
"mode",
|
|
31
|
+
"referrer",
|
|
32
|
+
"referrerPolicy",
|
|
33
|
+
"signal",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export const tagRequest = (defaults = {}) => {
|
|
37
|
+
const baseHeaders = new Headers(defaults.headers);
|
|
38
|
+
if (defaults.host !== undefined) {
|
|
39
|
+
if (baseHeaders.has("host")) {
|
|
40
|
+
throw new TypeError(
|
|
41
|
+
"tagRequest: both the 'host' sugar key and headers.host/Host were given in defaults -- remove one"
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
baseHeaders.set("host", defaults.host);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return async function (strings, ...substitutions) {
|
|
48
|
+
const { startLine, headerEntries, body } = await parseHttpText(
|
|
49
|
+
strings,
|
|
50
|
+
substitutions
|
|
51
|
+
);
|
|
52
|
+
const [method, url, httpVersion] = startLine.split(" ");
|
|
53
|
+
if (!method || !url || !httpVersion) {
|
|
54
|
+
throw new TypeError(
|
|
55
|
+
`tagRequest: template must start with a request line ("METHOD /path HTTP/1.1"), got: ${JSON.stringify(
|
|
56
|
+
startLine
|
|
57
|
+
)}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const baseUrl = defaults.baseUrl || "http://localhost";
|
|
62
|
+
const fullUrl =
|
|
63
|
+
url.startsWith("http://") || url.startsWith("https://")
|
|
64
|
+
? new URL(url)
|
|
65
|
+
: new URL(url.startsWith("/") ? url : `/${url}`, baseUrl);
|
|
66
|
+
|
|
67
|
+
const headers = mergeHeaderEntries(new Headers(baseHeaders), headerEntries);
|
|
68
|
+
|
|
69
|
+
const requestInit = { method, headers };
|
|
70
|
+
for (const key of PASSTHROUGH_REQUEST_INIT_KEYS) {
|
|
71
|
+
if (defaults[key] !== undefined) requestInit[key] = defaults[key];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const isBinaryBody = typeof body !== "string";
|
|
75
|
+
if (isBinaryBody) {
|
|
76
|
+
requestInit.body = body;
|
|
77
|
+
if (body instanceof ReadableStream) requestInit.duplex = "half";
|
|
78
|
+
if (!headers.has("Content-Type")) {
|
|
79
|
+
const contentType = inferBodyContentType(body);
|
|
80
|
+
if (contentType) headers.set("Content-Type", contentType);
|
|
81
|
+
}
|
|
82
|
+
} else if (body) {
|
|
83
|
+
requestInit.body = body;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (requestInit.body && !headers.has("Content-Length")) {
|
|
87
|
+
const size =
|
|
88
|
+
requestInit.body instanceof Blob
|
|
89
|
+
? requestInit.body.size
|
|
90
|
+
: typeof requestInit.body === "string"
|
|
91
|
+
? new Blob([requestInit.body]).size
|
|
92
|
+
: null;
|
|
93
|
+
if (size !== null) headers.set("Content-Length", size.toString());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return new Request(fullUrl, requestInit);
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export default tagRequest;
|
package/tag-response.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { parseHttpText } from "./utility/parse-http-text.mjs";
|
|
2
|
+
|
|
3
|
+
const inferBodyContentType = (body) => {
|
|
4
|
+
if (body instanceof ReadableStream) return "application/octet-stream";
|
|
5
|
+
if (body instanceof Blob) return body.type || "application/octet-stream";
|
|
6
|
+
if (body instanceof URLSearchParams)
|
|
7
|
+
return "application/x-www-form-urlencoded";
|
|
8
|
+
if (body instanceof ArrayBuffer || body instanceof Uint8Array)
|
|
9
|
+
return "application/octet-stream";
|
|
10
|
+
return null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const mergeHeaderEntries = (headerEntries) => {
|
|
14
|
+
const headers = new Headers();
|
|
15
|
+
const seen = new Set();
|
|
16
|
+
for (const { name, value } of headerEntries) {
|
|
17
|
+
const key = name.toLowerCase();
|
|
18
|
+
if (seen.has(key)) {
|
|
19
|
+
headers.append(name, value);
|
|
20
|
+
} else {
|
|
21
|
+
headers.set(name, value);
|
|
22
|
+
seen.add(key);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return headers;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const tagResponse = async function (strings, ...substitutions) {
|
|
29
|
+
const { startLine, headerEntries, body } = await parseHttpText(
|
|
30
|
+
strings,
|
|
31
|
+
substitutions
|
|
32
|
+
);
|
|
33
|
+
const [httpVersion, statusCodeText, ...statusTextParts] =
|
|
34
|
+
startLine.split(" ");
|
|
35
|
+
const status = parseInt(statusCodeText, 10);
|
|
36
|
+
if (!httpVersion?.startsWith("HTTP/") || Number.isNaN(status)) {
|
|
37
|
+
throw new TypeError(
|
|
38
|
+
`tagResponse: template must start with a status line ("HTTP/1.1 200 OK"), got: ${JSON.stringify(
|
|
39
|
+
startLine
|
|
40
|
+
)}`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
const statusText = statusTextParts.join(" ");
|
|
44
|
+
|
|
45
|
+
const headers = mergeHeaderEntries(headerEntries);
|
|
46
|
+
|
|
47
|
+
const isBinaryBody = typeof body !== "string";
|
|
48
|
+
let responseBody = body;
|
|
49
|
+
if (isBinaryBody) {
|
|
50
|
+
if (!headers.has("Content-Type")) {
|
|
51
|
+
const contentType = inferBodyContentType(body);
|
|
52
|
+
if (contentType) headers.set("Content-Type", contentType);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (responseBody && !headers.has("Content-Length")) {
|
|
57
|
+
const size =
|
|
58
|
+
responseBody instanceof Blob
|
|
59
|
+
? responseBody.size
|
|
60
|
+
: typeof responseBody === "string"
|
|
61
|
+
? new Blob([responseBody]).size
|
|
62
|
+
: null;
|
|
63
|
+
if (size !== null) headers.set("Content-Length", size.toString());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return new Response(status === 204 && !responseBody ? null : responseBody, {
|
|
67
|
+
status,
|
|
68
|
+
statusText,
|
|
69
|
+
headers,
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export default tagResponse;
|
package/test/index.mjs
CHANGED
package/test/route.mjs
CHANGED
|
@@ -87,6 +87,64 @@ 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
|
+
|
|
127
|
+
it("should apply a later function substitution's setHeader() after an earlier template header line (ordering regression)", async () => {
|
|
128
|
+
// Regression test: the first version of the create-route.mjs/
|
|
129
|
+
// parse-http-text.mjs backport resolved every function-valued
|
|
130
|
+
// substitution in a pre-pass, before any template header line was
|
|
131
|
+
// parsed -- so a context.setHeader() call from a function appearing
|
|
132
|
+
// *later* in the template always lost to an earlier template header
|
|
133
|
+
// line, reversing the original left-to-right "last write wins"
|
|
134
|
+
// ordering. Substitutions must now be resolved (and header lines
|
|
135
|
+
// applied) at the exact point the scan reaches them.
|
|
136
|
+
const route = createRoute()`HTTP/1.1 200 OK
|
|
137
|
+
X-Foo: from-template-line
|
|
138
|
+
Content-Type: text/plain
|
|
139
|
+
|
|
140
|
+
${async (_, { response }) => {
|
|
141
|
+
response.headers.set("X-Foo", "from-function-side-effect");
|
|
142
|
+
return "body";
|
|
143
|
+
}}`;
|
|
144
|
+
const response = await route(new Request("https://example.com"));
|
|
145
|
+
assert.strictEqual(response.headers.get("X-Foo"), "from-function-side-effect");
|
|
146
|
+
});
|
|
147
|
+
|
|
90
148
|
it("should handle streaming responses", async () => {
|
|
91
149
|
const route = createRoute({ streaming: true })`
|
|
92
150
|
${async (_, { response }) => {
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import { strict as assert } from "node:assert";
|
|
3
|
+
import { tagRequest, tagResponse } from "../index.mjs";
|
|
4
|
+
|
|
5
|
+
const createReadableStream = (data) =>
|
|
6
|
+
new ReadableStream({
|
|
7
|
+
start(controller) {
|
|
8
|
+
controller.enqueue(new TextEncoder().encode(data));
|
|
9
|
+
controller.close();
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
await test("tagRequest", async (t) => {
|
|
14
|
+
await t.test("basic request, Host header from template", async () => {
|
|
15
|
+
const request = await tagRequest()`POST /users HTTP/1.1
|
|
16
|
+
Host: example.com
|
|
17
|
+
Content-Type: application/x-www-form-urlencoded
|
|
18
|
+
|
|
19
|
+
name=A+B&email=a%40b.com`;
|
|
20
|
+
assert.equal(request.method, "POST");
|
|
21
|
+
// Host: example.com in the template sets the Host *header* -- it does
|
|
22
|
+
// not change which baseUrl a relative "/users" path resolves against
|
|
23
|
+
// (that's the separate `baseUrl` option, matching createRequest).
|
|
24
|
+
assert.equal(request.url, "http://localhost/users");
|
|
25
|
+
assert.equal(request.headers.get("host"), "example.com");
|
|
26
|
+
assert.equal(
|
|
27
|
+
request.headers.get("content-type"),
|
|
28
|
+
"application/x-www-form-urlencoded"
|
|
29
|
+
);
|
|
30
|
+
assert.equal(await request.text(), "name=A+B&email=a%40b.com");
|
|
31
|
+
assert.equal(request.headers.get("content-length"), "24");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
await t.test("host sugar key", async () => {
|
|
35
|
+
const request = await tagRequest({ host: "example.com" })`GET /path HTTP/1.1
|
|
36
|
+
Accept: application/json`;
|
|
37
|
+
assert.equal(request.headers.get("host"), "example.com");
|
|
38
|
+
assert.equal(request.headers.get("accept"), "application/json");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
await t.test("host sugar key conflicts with explicit headers.host -- throws", () => {
|
|
42
|
+
assert.throws(
|
|
43
|
+
() => tagRequest({ host: "a", headers: { Host: "b" } }),
|
|
44
|
+
TypeError
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
await t.test(
|
|
49
|
+
"defaults.headers is a fallback, template headers win per-key",
|
|
50
|
+
async () => {
|
|
51
|
+
const request = await tagRequest({
|
|
52
|
+
headers: { "X-Default": "from-defaults", "Content-Type": "text/x-default" },
|
|
53
|
+
})`POST /x HTTP/1.1
|
|
54
|
+
Content-Type: text/plain
|
|
55
|
+
|
|
56
|
+
body`;
|
|
57
|
+
assert.equal(request.headers.get("x-default"), "from-defaults");
|
|
58
|
+
assert.equal(request.headers.get("content-type"), "text/plain");
|
|
59
|
+
}
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
await t.test("missing request line throws", async () => {
|
|
63
|
+
await assert.rejects(
|
|
64
|
+
() => tagRequest()`Content-Type: text/plain
|
|
65
|
+
|
|
66
|
+
oops`,
|
|
67
|
+
TypeError
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await t.test(
|
|
72
|
+
"a substituted header value containing a colon is not truncated",
|
|
73
|
+
async () => {
|
|
74
|
+
const request = await tagRequest()`POST /x HTTP/1.1
|
|
75
|
+
X-Note: ${"value: with: colons: inside"}
|
|
76
|
+
Content-Type: text/plain
|
|
77
|
+
|
|
78
|
+
body`;
|
|
79
|
+
assert.equal(
|
|
80
|
+
request.headers.get("x-note"),
|
|
81
|
+
"value: with: colons: inside"
|
|
82
|
+
);
|
|
83
|
+
assert.equal(await request.text(), "body");
|
|
84
|
+
}
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
await t.test(
|
|
88
|
+
"a substituted value cannot inject a new header or end headers early",
|
|
89
|
+
async () => {
|
|
90
|
+
// If the parser re-scanned this substitution's text for structure,
|
|
91
|
+
// it would register a second "injected" header and/or end the
|
|
92
|
+
// header section early. The platform's Headers validation rejects
|
|
93
|
+
// a raw embedded newline in a header value outright, which is the
|
|
94
|
+
// correct outcome -- it proves the value was handed to Headers.set
|
|
95
|
+
// as one literal string, not re-tokenized into separate lines.
|
|
96
|
+
await assert.rejects(
|
|
97
|
+
() =>
|
|
98
|
+
tagRequest()`POST /x HTTP/1.1
|
|
99
|
+
X-Note: ${"line1\ninjected: value"}
|
|
100
|
+
Content-Type: text/plain
|
|
101
|
+
|
|
102
|
+
body`,
|
|
103
|
+
TypeError
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
await t.test("repeated header name accumulates (not last-wins)", async () => {
|
|
109
|
+
const request = await tagRequest()`POST /x HTTP/1.1
|
|
110
|
+
X-Tag: a
|
|
111
|
+
X-Tag: b
|
|
112
|
+
Content-Type: text/plain
|
|
113
|
+
|
|
114
|
+
body`;
|
|
115
|
+
// Headers folds repeated non-Set-Cookie values with ", " on .get() --
|
|
116
|
+
// the point here is both survive, rather than "b" silently winning.
|
|
117
|
+
assert.equal(request.headers.get("x-tag"), "a, b");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await t.test("Headers object substitution merges directly", async () => {
|
|
121
|
+
const extra = new Headers({ "X-From-Headers-Object": "yes" });
|
|
122
|
+
const request = await tagRequest()`POST /x HTTP/1.1
|
|
123
|
+
${extra}
|
|
124
|
+
Content-Type: text/plain
|
|
125
|
+
|
|
126
|
+
body`;
|
|
127
|
+
assert.equal(request.headers.get("x-from-headers-object"), "yes");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await t.test("binary body passthrough (Uint8Array)", async () => {
|
|
131
|
+
const bin = new Uint8Array([1, 2, 3]);
|
|
132
|
+
const request = await tagRequest()`POST /up HTTP/1.1
|
|
133
|
+
|
|
134
|
+
${bin}`;
|
|
135
|
+
const buf = await request.arrayBuffer();
|
|
136
|
+
assert.deepEqual([...new Uint8Array(buf)], [1, 2, 3]);
|
|
137
|
+
assert.equal(request.headers.get("content-type"), "application/octet-stream");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await t.test("binary body passthrough (ReadableStream)", async () => {
|
|
141
|
+
const request = await tagRequest()`POST /up HTTP/1.1
|
|
142
|
+
|
|
143
|
+
${createReadableStream("streamed")}`;
|
|
144
|
+
assert.equal(await request.text(), "streamed");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await t.test(
|
|
148
|
+
"binary body substitution mixed with other body content throws",
|
|
149
|
+
async () => {
|
|
150
|
+
const bin = new Uint8Array([1, 2, 3]);
|
|
151
|
+
await assert.rejects(
|
|
152
|
+
() => tagRequest()`POST /up HTTP/1.1
|
|
153
|
+
|
|
154
|
+
prefix ${bin}`,
|
|
155
|
+
TypeError
|
|
156
|
+
);
|
|
157
|
+
await assert.rejects(
|
|
158
|
+
() => tagRequest()`POST /up HTTP/1.1
|
|
159
|
+
|
|
160
|
+
${bin}${new Uint8Array([9])}`,
|
|
161
|
+
TypeError
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
await t.test("Content-Length auto-filled only when absent", async () => {
|
|
167
|
+
const auto = await tagRequest()`POST /x HTTP/1.1
|
|
168
|
+
Content-Type: text/plain
|
|
169
|
+
|
|
170
|
+
hello`;
|
|
171
|
+
assert.equal(auto.headers.get("content-length"), "5");
|
|
172
|
+
|
|
173
|
+
const explicit = await tagRequest()`POST /x HTTP/1.1
|
|
174
|
+
Content-Type: text/plain
|
|
175
|
+
Content-Length: 999
|
|
176
|
+
|
|
177
|
+
hello`;
|
|
178
|
+
assert.equal(
|
|
179
|
+
explicit.headers.get("content-length"),
|
|
180
|
+
"999",
|
|
181
|
+
"an explicitly-declared Content-Length is never overwritten, even if wrong"
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await t.test("relative vs absolute URLs, baseUrl option", async () => {
|
|
186
|
+
const relative = await tagRequest({ baseUrl: "https://api.example.com" })`GET /search?q=1 HTTP/1.1`;
|
|
187
|
+
assert.equal(relative.url, "https://api.example.com/search?q=1");
|
|
188
|
+
|
|
189
|
+
const absolute = await tagRequest()`DELETE https://api.example.com/users/1 HTTP/1.1`;
|
|
190
|
+
assert.equal(absolute.url, "https://api.example.com/users/1");
|
|
191
|
+
assert.equal(absolute.method, "DELETE");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await test("tagResponse", async (t) => {
|
|
196
|
+
await t.test("basic response", async () => {
|
|
197
|
+
const response = await tagResponse`HTTP/1.1 201 Created
|
|
198
|
+
Content-Type: application/json
|
|
199
|
+
Location: http://example.com/users/123
|
|
200
|
+
|
|
201
|
+
{"id":123}`;
|
|
202
|
+
assert.equal(response.status, 201);
|
|
203
|
+
assert.equal(response.statusText, "Created");
|
|
204
|
+
assert.equal(response.headers.get("location"), "http://example.com/users/123");
|
|
205
|
+
assert.deepEqual(await response.json(), { id: 123 });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await t.test("missing status line throws", async () => {
|
|
209
|
+
await assert.rejects(
|
|
210
|
+
() => tagResponse`Content-Type: text/plain
|
|
211
|
+
|
|
212
|
+
oops`,
|
|
213
|
+
TypeError
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await t.test("repeated Set-Cookie accumulates, not last-wins", async () => {
|
|
218
|
+
const response = await tagResponse`HTTP/1.1 200 OK
|
|
219
|
+
Set-Cookie: a=1
|
|
220
|
+
Set-Cookie: b=2
|
|
221
|
+
Content-Type: text/plain
|
|
222
|
+
|
|
223
|
+
hello`;
|
|
224
|
+
const cookies = response.headers.getSetCookie
|
|
225
|
+
? response.headers.getSetCookie()
|
|
226
|
+
: [...response.headers.entries()]
|
|
227
|
+
.filter(([k]) => k === "set-cookie")
|
|
228
|
+
.map(([, v]) => v);
|
|
229
|
+
assert.deepEqual(cookies, ["a=1", "b=2"]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await t.test("204 has a null body", async () => {
|
|
233
|
+
const response = await tagResponse`HTTP/1.1 204 No Content
|
|
234
|
+
`;
|
|
235
|
+
assert.equal(response.status, 204);
|
|
236
|
+
assert.equal(await response.text(), "");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await t.test("binary body passthrough (Blob)", async () => {
|
|
240
|
+
const response = await tagResponse`HTTP/1.1 200 OK
|
|
241
|
+
|
|
242
|
+
${new Blob(["hello blob"], { type: "text/plain" })}`;
|
|
243
|
+
assert.equal(response.headers.get("content-type"), "text/plain");
|
|
244
|
+
assert.equal(await response.text(), "hello blob");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
await t.test("Content-Length auto-filled only when absent", async () => {
|
|
248
|
+
const auto = await tagResponse`HTTP/1.1 200 OK
|
|
249
|
+
|
|
250
|
+
hello`;
|
|
251
|
+
assert.equal(auto.headers.get("content-length"), "5");
|
|
252
|
+
});
|
|
253
|
+
});
|
package/test/types/fixture.ts
CHANGED
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
createRoute,
|
|
34
34
|
createRequest,
|
|
35
35
|
createResponse,
|
|
36
|
+
tagRequest,
|
|
37
|
+
tagResponse,
|
|
36
38
|
deconstruct,
|
|
37
39
|
cook,
|
|
38
40
|
HTTPExpression,
|
|
@@ -101,6 +103,24 @@ ok`;
|
|
|
101
103
|
}
|
|
102
104
|
void checkResponse;
|
|
103
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
|
+
|
|
104
124
|
// deconstruct: tagged-template => { strings, substitutions, raw }
|
|
105
125
|
const deconstructed = deconstruct`GET /${"foo"}`;
|
|
106
126
|
const deconstructedStrings: string[] = deconstructed.strings;
|
package/types.d.ts
CHANGED
|
@@ -130,6 +130,42 @@ export type CreateResponse = (
|
|
|
130
130
|
...substitutions: any[]
|
|
131
131
|
) => Response | Promise<Response>;
|
|
132
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
|
+
|
|
133
169
|
// Result of deconstruct(): the raw pieces of a tagged template call.
|
|
134
170
|
export type DeconstructResult = {
|
|
135
171
|
strings: string[];
|
|
@@ -186,6 +222,8 @@ export declare const createRouter: CreateRouter;
|
|
|
186
222
|
export declare const createRoute: CreateRoute;
|
|
187
223
|
export declare const createRequest: CreateRequest;
|
|
188
224
|
export declare const createResponse: CreateResponse;
|
|
225
|
+
export declare const tagRequest: TagRequest;
|
|
226
|
+
export declare const tagResponse: TagResponse;
|
|
189
227
|
export declare const deconstruct: Deconstruct;
|
|
190
228
|
export declare const cook: Cook;
|
|
191
229
|
export declare const HTTPExpression: HTTPExpressionFn;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Shared positional parser for tag-request.mjs / tag-response.mjs, and
|
|
2
|
+
// (via create-route.mjs's function-substitution pre-pass) create-route.mjs.
|
|
3
|
+
//
|
|
4
|
+
// The bugs this exists to fix: create-request.mjs flattens a whole tagged
|
|
5
|
+
// template to one string before splitting on "\n" (and splits header
|
|
6
|
+
// lines on the literal substring ": ", truncating any value containing a
|
|
7
|
+
// second one); create-route.mjs re-feeds a stringified substitution back
|
|
8
|
+
// through its own line tokenizer, and uses headers.set() instead of
|
|
9
|
+
// .append(), so a second Set-Cookie line overwrites the first instead of
|
|
10
|
+
// accumulating. In all three cases, a substituted value containing "\n"
|
|
11
|
+
// or ":" can get reinterpreted as if the *caller* had typed new HTTP
|
|
12
|
+
// syntax.
|
|
13
|
+
//
|
|
14
|
+
// The fix: only ever scan the template's literal `strings` pieces for
|
|
15
|
+
// structure (the start line, header lines, the blank line that ends
|
|
16
|
+
// them). A substitution's value is appended verbatim to whatever's
|
|
17
|
+
// currently being built -- never re-split, never re-scanned -- so it
|
|
18
|
+
// can't introduce a header line, end the headers early, or do anything
|
|
19
|
+
// else the literal text didn't already set up.
|
|
20
|
+
const BINARY_BODY_TYPES = (value) =>
|
|
21
|
+
value instanceof Blob ||
|
|
22
|
+
value instanceof Uint8Array ||
|
|
23
|
+
value instanceof ArrayBuffer ||
|
|
24
|
+
value instanceof ReadableStream ||
|
|
25
|
+
value instanceof FormData ||
|
|
26
|
+
value instanceof URLSearchParams;
|
|
27
|
+
|
|
28
|
+
// options.isStartLine(line): called once, on the first complete line, to
|
|
29
|
+
// decide whether it's a real start line (request-line / status-line) or
|
|
30
|
+
// whether this template has none at all and that line is actually the
|
|
31
|
+
// first line of the body -- create-route.mjs's templates can be
|
|
32
|
+
// headers-and-body-only or body-only, falling back to its `init` object
|
|
33
|
+
// for status/headers in that case. Defaults to "yes, always" for
|
|
34
|
+
// tagRequest/tagResponse, which require a real start line.
|
|
35
|
+
// options.trimBodyLines: create-route.mjs's historical behavior trims
|
|
36
|
+
// *every* line, including body lines, before accumulating them (so an
|
|
37
|
+
// indented template literal's body doesn't carry that indentation into
|
|
38
|
+
// the real response body). tagRequest/tagResponse deliberately do NOT do
|
|
39
|
+
// this -- body content there is literal, preserved exactly as written,
|
|
40
|
+
// since trimming could corrupt meaningful whitespace in a text body.
|
|
41
|
+
// Defaults to false (the strict behavior); create-route.mjs passes true.
|
|
42
|
+
// options.resolveSubstitution(value, index): called (and awaited) for
|
|
43
|
+
// each substitution at the exact point the scan reaches it -- not
|
|
44
|
+
// pre-resolved up front -- so a caller like create-route.mjs can run a
|
|
45
|
+
// function-valued substitution's side effects (context.setHeader, etc.)
|
|
46
|
+
// in the same left-to-right order the original chunk-by-chunk processor
|
|
47
|
+
// used. Defaults to the identity function.
|
|
48
|
+
// options.onHeaderLine(name, value): called immediately as each header
|
|
49
|
+
// line (or Headers-object substitution) is parsed, interleaved with
|
|
50
|
+
// resolveSubstitution calls in the same left-to-right order -- lets
|
|
51
|
+
// create-route.mjs apply a template header line to its live `headers`
|
|
52
|
+
// object right away, so a *later* function substitution's
|
|
53
|
+
// context.setHeader() call still overwrites it (matching the original
|
|
54
|
+
// implementation's ordering), rather than only being visible once the
|
|
55
|
+
// whole parse finishes.
|
|
56
|
+
export const parseHttpText = async (strings, substitutions, options = {}) => {
|
|
57
|
+
const {
|
|
58
|
+
isStartLine = () => true,
|
|
59
|
+
trimBodyLines = false,
|
|
60
|
+
resolveSubstitution = (value) => value,
|
|
61
|
+
onHeaderLine = null,
|
|
62
|
+
} = options;
|
|
63
|
+
|
|
64
|
+
const stringsArr = [...strings];
|
|
65
|
+
stringsArr[0] = stringsArr[0].replace(/^[ \t\r\n]+/, "");
|
|
66
|
+
stringsArr[stringsArr.length - 1] = stringsArr[stringsArr.length - 1].replace(
|
|
67
|
+
/[ \t\r\n]+$/,
|
|
68
|
+
""
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
let zone = "start"; // "start" -> "headers" -> "body"
|
|
72
|
+
let lineBuffer = "";
|
|
73
|
+
// A binary substitution can't be classified as "body content" the
|
|
74
|
+
// moment it's seen while zone is "start" -- that line might turn out to
|
|
75
|
+
// be a real start line (invalid) or, once nothing else precedes it,
|
|
76
|
+
// the first line of a body-only template (valid, same as it landing in
|
|
77
|
+
// the body zone directly). Held here until the line is flushed and its
|
|
78
|
+
// true zone is known.
|
|
79
|
+
let pendingBinary = null;
|
|
80
|
+
let startLine = null;
|
|
81
|
+
const headerEntries = []; // ordered [{ name, value }], duplicates allowed
|
|
82
|
+
let bodyText = "";
|
|
83
|
+
let binaryBody = null;
|
|
84
|
+
let binaryBodyCount = 0;
|
|
85
|
+
let bodyHasOtherContent = false;
|
|
86
|
+
|
|
87
|
+
const commitBodyLine = (rawLine, isFinal) => {
|
|
88
|
+
const line = trimBodyLines ? rawLine.trim() : rawLine;
|
|
89
|
+
if (pendingBinary) {
|
|
90
|
+
if (line.trim() !== "" || bodyHasOtherContent) {
|
|
91
|
+
throw new TypeError(
|
|
92
|
+
"parseHttpText: a binary body substitution must be the sole content of the body zone -- it can't be combined with literal text or another substitution"
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
binaryBodyCount++;
|
|
96
|
+
binaryBody = pendingBinary;
|
|
97
|
+
pendingBinary = null;
|
|
98
|
+
} else {
|
|
99
|
+
bodyText += isFinal ? line : line + "\n";
|
|
100
|
+
if (line.trim() !== "") bodyHasOtherContent = true;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const flushLine = () => {
|
|
105
|
+
if (zone === "start") {
|
|
106
|
+
if (isStartLine(lineBuffer)) {
|
|
107
|
+
if (pendingBinary) {
|
|
108
|
+
throw new TypeError(
|
|
109
|
+
"parseHttpText: a binary substitution cannot appear within the start line"
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
startLine = lineBuffer;
|
|
113
|
+
zone = "headers";
|
|
114
|
+
} else {
|
|
115
|
+
zone = "body";
|
|
116
|
+
commitBodyLine(lineBuffer, false);
|
|
117
|
+
}
|
|
118
|
+
} else if (zone === "headers") {
|
|
119
|
+
if (pendingBinary) {
|
|
120
|
+
throw new TypeError(
|
|
121
|
+
"parseHttpText: a binary substitution cannot appear within a header line"
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (lineBuffer.trim() === "") {
|
|
125
|
+
zone = "body";
|
|
126
|
+
} else {
|
|
127
|
+
const colonIndex = lineBuffer.indexOf(":");
|
|
128
|
+
if (colonIndex > 0) {
|
|
129
|
+
const name = lineBuffer.slice(0, colonIndex).trim();
|
|
130
|
+
const value = lineBuffer.slice(colonIndex + 1).trim();
|
|
131
|
+
headerEntries.push({ name, value });
|
|
132
|
+
if (onHeaderLine) onHeaderLine(name, value);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
lineBuffer = "";
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
for (let i = 0; i < stringsArr.length; i++) {
|
|
140
|
+
const parts = stringsArr[i].split("\n");
|
|
141
|
+
for (let p = 0; p < parts.length; p++) {
|
|
142
|
+
if (p > 0) {
|
|
143
|
+
if (zone === "body") {
|
|
144
|
+
commitBodyLine(lineBuffer, false);
|
|
145
|
+
} else {
|
|
146
|
+
flushLine();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
lineBuffer += parts[p];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (i < substitutions.length) {
|
|
153
|
+
const sub = await resolveSubstitution(substitutions[i], i);
|
|
154
|
+
if (sub instanceof Headers) {
|
|
155
|
+
for (const [name, value] of sub) {
|
|
156
|
+
headerEntries.push({ name, value });
|
|
157
|
+
if (onHeaderLine) onHeaderLine(name, value);
|
|
158
|
+
}
|
|
159
|
+
} else if (zone !== "headers" && BINARY_BODY_TYPES(sub)) {
|
|
160
|
+
if (pendingBinary) {
|
|
161
|
+
throw new TypeError(
|
|
162
|
+
"parseHttpText: more than one binary body substitution found -- a binary value must be the sole content of the body"
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
pendingBinary = sub;
|
|
166
|
+
} else if (sub !== undefined && sub !== null) {
|
|
167
|
+
if (zone === "body") bodyHasOtherContent = true;
|
|
168
|
+
lineBuffer += String(sub);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (zone === "body") {
|
|
174
|
+
commitBodyLine(lineBuffer, true);
|
|
175
|
+
} else {
|
|
176
|
+
flushLine();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
startLine,
|
|
181
|
+
headerEntries,
|
|
182
|
+
body: binaryBodyCount === 1 ? binaryBody : bodyText.trim(),
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
export default parseHttpText;
|