@johnhenry/letterpress 0.2.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/CHANGELOG.md +28 -0
- package/create-route.mjs +33 -41
- package/package.json +1 -1
- package/tag-request.mjs +1 -1
- package/tag-response.mjs +1 -1
- package/test/route.mjs +21 -0
- package/utility/parse-http-text.mjs +30 -8
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,34 @@ 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
|
+
|
|
9
37
|
## [0.2.0] - 2026-09-30
|
|
10
38
|
|
|
11
39
|
### Added
|
package/create-route.mjs
CHANGED
|
@@ -2,24 +2,6 @@ import { parseHttpText } from "./utility/parse-http-text.mjs";
|
|
|
2
2
|
|
|
3
3
|
const DEFAULT_REQUEST = () => new Request("http://.");
|
|
4
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
|
-
|
|
23
5
|
export const createRoute = (initOrMiddleware) => {
|
|
24
6
|
const getInit =
|
|
25
7
|
typeof initOrMiddleware === "function"
|
|
@@ -49,28 +31,40 @@ export const createRoute = (initOrMiddleware) => {
|
|
|
49
31
|
...additionalContext,
|
|
50
32
|
};
|
|
51
33
|
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
{
|
|
70
|
-
isStartLine: (line) => line.startsWith("HTTP/"),
|
|
71
|
-
trimBodyLines: true,
|
|
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);
|
|
72
51
|
}
|
|
73
|
-
|
|
52
|
+
};
|
|
53
|
+
|
|
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
|
+
});
|
|
74
68
|
|
|
75
69
|
if (startLine !== null) {
|
|
76
70
|
const [, statusCodeText, ...statusTextParts] = startLine.split(" ");
|
|
@@ -81,8 +75,6 @@ export const createRoute = (initOrMiddleware) => {
|
|
|
81
75
|
}
|
|
82
76
|
}
|
|
83
77
|
|
|
84
|
-
mergeHeaderEntries(headers, headerEntries);
|
|
85
|
-
|
|
86
78
|
let responseBody = body;
|
|
87
79
|
const isBinaryBody = typeof responseBody !== "string";
|
|
88
80
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnhenry/letterpress",
|
|
3
|
-
"version": "0.2.
|
|
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
CHANGED
|
@@ -45,7 +45,7 @@ export const tagRequest = (defaults = {}) => {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
return async function (strings, ...substitutions) {
|
|
48
|
-
const { startLine, headerEntries, body } = parseHttpText(
|
|
48
|
+
const { startLine, headerEntries, body } = await parseHttpText(
|
|
49
49
|
strings,
|
|
50
50
|
substitutions
|
|
51
51
|
);
|
package/tag-response.mjs
CHANGED
|
@@ -26,7 +26,7 @@ const mergeHeaderEntries = (headerEntries) => {
|
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
export const tagResponse = async function (strings, ...substitutions) {
|
|
29
|
-
const { startLine, headerEntries, body } = parseHttpText(
|
|
29
|
+
const { startLine, headerEntries, body } = await parseHttpText(
|
|
30
30
|
strings,
|
|
31
31
|
substitutions
|
|
32
32
|
);
|
package/test/route.mjs
CHANGED
|
@@ -124,6 +124,27 @@ ${"body text"}`;
|
|
|
124
124
|
assert.strictEqual(await response.text(), "body text");
|
|
125
125
|
});
|
|
126
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
|
+
|
|
127
148
|
it("should handle streaming responses", async () => {
|
|
128
149
|
const route = createRoute({ streaming: true })`
|
|
129
150
|
${async (_, { response }) => {
|
|
@@ -39,8 +39,27 @@ const BINARY_BODY_TYPES = (value) =>
|
|
|
39
39
|
// this -- body content there is literal, preserved exactly as written,
|
|
40
40
|
// since trimming could corrupt meaningful whitespace in a text body.
|
|
41
41
|
// Defaults to false (the strict behavior); create-route.mjs passes true.
|
|
42
|
-
|
|
43
|
-
|
|
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;
|
|
44
63
|
|
|
45
64
|
const stringsArr = [...strings];
|
|
46
65
|
stringsArr[0] = stringsArr[0].replace(/^[ \t\r\n]+/, "");
|
|
@@ -107,10 +126,10 @@ export const parseHttpText = (strings, substitutions, options = {}) => {
|
|
|
107
126
|
} else {
|
|
108
127
|
const colonIndex = lineBuffer.indexOf(":");
|
|
109
128
|
if (colonIndex > 0) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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);
|
|
114
133
|
}
|
|
115
134
|
}
|
|
116
135
|
}
|
|
@@ -131,9 +150,12 @@ export const parseHttpText = (strings, substitutions, options = {}) => {
|
|
|
131
150
|
}
|
|
132
151
|
|
|
133
152
|
if (i < substitutions.length) {
|
|
134
|
-
const sub = substitutions[i];
|
|
153
|
+
const sub = await resolveSubstitution(substitutions[i], i);
|
|
135
154
|
if (sub instanceof Headers) {
|
|
136
|
-
for (const [name, value] of sub)
|
|
155
|
+
for (const [name, value] of sub) {
|
|
156
|
+
headerEntries.push({ name, value });
|
|
157
|
+
if (onHeaderLine) onHeaderLine(name, value);
|
|
158
|
+
}
|
|
137
159
|
} else if (zone !== "headers" && BINARY_BODY_TYPES(sub)) {
|
|
138
160
|
if (pendingBinary) {
|
|
139
161
|
throw new TypeError(
|