@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 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
- // 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
- }
65
-
66
- const { startLine, headerEntries, body } = parseHttpText(
67
- strings,
68
- resolvedSubstitutions,
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.0",
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
- export const parseHttpText = (strings, substitutions, options = {}) => {
43
- const { isStartLine = () => true, trimBodyLines = false } = options;
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
- headerEntries.push({
111
- name: lineBuffer.slice(0, colonIndex).trim(),
112
- value: lineBuffer.slice(colonIndex + 1).trim(),
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) headerEntries.push({ name, value });
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(