@johnhenry/letterpress 0.2.0 → 0.2.2

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.
@@ -12,3 +12,6 @@ jobs:
12
12
  node-versions: '["26"]'
13
13
  os: '["ubuntu-latest"]'
14
14
  gate-commands: npm test
15
+
16
+ workflow-lint:
17
+ uses: johnhenry/workflows/.github/workflows/workflow-lint.yml@v1
@@ -1,25 +1,22 @@
1
1
  name: Publish to npm
2
2
 
3
+ # Publish model: main is the release branch. A push to main publishes only if
4
+ # package.json's version isn't on npm yet (otherwise a clean no-op), then tags
5
+ # v<version> and creates a GitHub Release. See johnhenry/workflows' README.
3
6
  on:
4
- release:
5
- types: [published]
6
7
  push:
7
- # Redundant with the release trigger above -- see fileable's
8
- # publish.yml / johnhenry/workflows' README for the full incident
9
- # writeup: creating a release very soon after a push/merge to the
10
- # same commit can silently drop the release event for a job whose
11
- # body is uses: <external-repo>/...@v1. This gives a second,
12
- # idempotency-safe chance to fire.
13
- tags:
14
- - "v*"
8
+ branches: [main]
15
9
  workflow_dispatch: {}
16
10
 
11
+ concurrency:
12
+ group: publish-${{ github.ref }}
13
+ cancel-in-progress: false
14
+
17
15
  jobs:
18
16
  publish:
19
17
  permissions:
20
- contents: read
21
- id-token: write # required by the reusable workflow's provenance step --
22
- # see johnhenry/workflows' README for why this must be declared here too.
18
+ contents: write # tag + GitHub Release by-product of npm-publish.yml
19
+ id-token: write # npm provenance
23
20
  uses: johnhenry/workflows/.github/workflows/npm-publish.yml@v1
24
21
  with:
25
22
  node-version: "26"
package/CHANGELOG.md CHANGED
@@ -6,6 +6,44 @@ 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.2] - 2026-10-14
10
+
11
+ ### Fixed
12
+
13
+ - 🐛 **Multi-line bodies duplicated earlier lines** (#20). In
14
+ `utility/parse-http-text.mjs`, the line buffer was not cleared after each
15
+ committed body line, so a body of `a\nb\nc` came out as `a\nab\nabc`.
16
+ This affected `createResponse`/`createRoute`/`tagRequest`/`tagResponse`
17
+ (a regression since `0.2.0`; `0.0.3` was correct).
18
+
19
+ ## [0.2.1] - 2026-10-01
20
+
21
+ ### Fixed
22
+
23
+ - 🐛 **A later function substitution's `context.setHeader()` stopped
24
+ overwriting an earlier template header line**, reversing `0.2.0`'s own
25
+ create-route.mjs backport. That backport resolved every function-valued
26
+ substitution in a pre-pass, before any template header line was
27
+ applied -- so a template header line always won, regardless of whether
28
+ a function substitution calling `setHeader` for the same name appeared
29
+ *after* it (originally, whichever ran later always won, matching plain
30
+ left-to-right template evaluation). `utility/parse-http-text.mjs` now
31
+ takes a `resolveSubstitution` callback invoked at the exact point the
32
+ scan reaches each substitution, and an `onHeaderLine` callback invoked
33
+ immediately per header line, instead of batching either until the whole
34
+ parse finishes -- restoring the original ordering while still sharing
35
+ one parser implementation. `tagRequest`/`tagResponse` are unaffected
36
+ (they have no function-substitution/side-effect concept to reorder).
37
+ Found by deliberately re-testing `0.2.0`'s backport against the
38
+ pre-refactor code's actual behavior, not by inspection.
39
+ - A function-valued substitution returning a `Headers` object is now
40
+ merged directly, like a directly-substituted `Headers` object already
41
+ was -- previously it fell through to `.toString()`, producing the
42
+ literal text `"[object Headers]"` in the response body and silently
43
+ dropping the headers. Not separately tested before (no prior test
44
+ covered this combination); noted here since it's a user-visible
45
+ behavior difference from `0.2.0` and earlier, not just a `0.2.0` revert.
46
+
9
47
  ## [0.2.0] - 2026-09-30
10
48
 
11
49
  ### Added
@@ -89,7 +127,7 @@ already behaved) is the one `create-route.mjs` was always meant to have.
89
127
  carried over during the leroute -> `@johnhenry/letterpress` rename --
90
128
  it simply wasn't in the initial `@johnhenry/letterpress` source, a gap
91
129
  in that migration rather than an intentional removal. It was discovered
92
- while porting a downstream consumer, `prism` (a live HTTP request
130
+ while porting a private downstream consumer (a live HTTP request
93
131
  inspector/proxy), whose proxy mode depends on it for rewriting proxied
94
132
  requests/responses. Ported unchanged in behavior as `rewrite.mjs`
95
133
  (`createRewriter`, plus `rewriteRequest`/`rewriteResponse`/`addRule`/
@@ -105,7 +143,7 @@ already behaved) is the one `create-route.mjs` was always meant to have.
105
143
 
106
144
  - ✨ **`router.mount(prefix, subHandler)`**: another `leroute` capability
107
145
  (`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
146
+ found via the same port -- that app's own top-level router uses it
109
147
  to mount its dashboard sub-router under `/inspect`. Delegates every
110
148
  request under `prefix` to `subHandler` with the prefix stripped from the
111
149
  forwarded request's path; `subHandler` may be a plain `(Request) =>
@@ -127,9 +165,9 @@ already behaved) is the one `create-route.mjs` was always meant to have.
127
165
  and forwarded to `defaultHandler` too. This was also present in `leroute`
128
166
  (`router(request, ctx = {})`) and dropped during the rename; brought back
129
167
  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
168
+ (used by that app to reconstruct the full request path for display in its
131
169
  captured-request feed, purely cosmetic but a real fidelity gap otherwise)
132
- -- caught by testing `mount()` end-to-end against `prism` itself, not by
170
+ -- caught by testing `mount()` end-to-end against that app itself, not by
133
171
  a direct comparison against the old source this time. Existing single-arg
134
172
  `router(request)` callers are unaffected (`ctx` defaults to `{}`).
135
173
 
package/README.md CHANGED
@@ -265,7 +265,7 @@ rewriter.addRule({ match: { path: "/teapot" }, action: { setStatus: 418 } });
265
265
  > This existed in `leroute` (the package letterpress was renamed from --
266
266
  > see the provenance note near the top of this README) but was dropped
267
267
  > during that rename. It's ported here, discovered missing while porting a
268
- > downstream consumer (`prism`, a request inspector/proxy) that relies on
268
+ > downstream consumer (a private request inspector/proxy) that relies on
269
269
  > it for proxy-mode rewriting -- see CHANGELOG.md.
270
270
 
271
271
  ### `deconstruct`, `cook`
@@ -318,13 +318,6 @@ is designed to pair with a sibling package that does.
318
318
  `Route`-shaped function works too; `leserve` is the tested, documented
319
319
  pairing, not a hard dependency (it's a `devDependency` here, used only in
320
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.
328
321
 
329
322
  ## 🤝 Contributing
330
323
 
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.2",
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/rewrite.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  * see the README's provenance note) as `createRewriter`, but was dropped
9
9
  * during the leroute -> @johnhenry/letterpress rename. It's ported here
10
10
  * unchanged in behavior, discovered missing while porting a downstream
11
- * consumer (`prism`, a request inspector/proxy) that relies on it for
11
+ * consumer (a private request inspector/proxy) that relies on it for
12
12
  * proxy-mode rewriting.
13
13
  *
14
14
  * Rules format:
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 }) => {
@@ -1,6 +1,6 @@
1
1
  import { test } from "node:test";
2
2
  import { strict as assert } from "node:assert";
3
- import { tagRequest, tagResponse } from "../index.mjs";
3
+ import { tagRequest, tagResponse, createResponse } from "../index.mjs";
4
4
 
5
5
  const createReadableStream = (data) =>
6
6
  new ReadableStream({
@@ -251,3 +251,34 @@ hello`;
251
251
  assert.equal(auto.headers.get("content-length"), "5");
252
252
  });
253
253
  });
254
+
255
+ test("multi-line bodies are not duplicated (issue #20)", async (t) => {
256
+ await t.test("tagResponse", async () => {
257
+ const response = await tagResponse`HTTP/1.1 200 OK
258
+
259
+ a
260
+ b
261
+ c`;
262
+ assert.equal(await response.text(), "a\nb\nc");
263
+ });
264
+
265
+ await t.test("tagRequest", async () => {
266
+ const request = await tagRequest()`POST /x HTTP/1.1
267
+
268
+ a
269
+ b
270
+ c`;
271
+ assert.equal(await request.text(), "a\nb\nc");
272
+ });
273
+
274
+ await t.test("createResponse with substitution in a later line", async () => {
275
+ const name = "x";
276
+ const response = await createResponse`HTTP/1.1 200 OK
277
+ Content-Type: text/plain
278
+
279
+ line1
280
+ line2 ${name}
281
+ line3`;
282
+ assert.equal(await response.text(), "line1\nline2 x\nline3");
283
+ });
284
+ });
@@ -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
  }
@@ -123,6 +142,7 @@ export const parseHttpText = (strings, substitutions, options = {}) => {
123
142
  if (p > 0) {
124
143
  if (zone === "body") {
125
144
  commitBodyLine(lineBuffer, false);
145
+ lineBuffer = "";
126
146
  } else {
127
147
  flushLine();
128
148
  }
@@ -131,9 +151,12 @@ export const parseHttpText = (strings, substitutions, options = {}) => {
131
151
  }
132
152
 
133
153
  if (i < substitutions.length) {
134
- const sub = substitutions[i];
154
+ const sub = await resolveSubstitution(substitutions[i], i);
135
155
  if (sub instanceof Headers) {
136
- for (const [name, value] of sub) headerEntries.push({ name, value });
156
+ for (const [name, value] of sub) {
157
+ headerEntries.push({ name, value });
158
+ if (onHeaderLine) onHeaderLine(name, value);
159
+ }
137
160
  } else if (zone !== "headers" && BINARY_BODY_TYPES(sub)) {
138
161
  if (pendingBinary) {
139
162
  throw new TypeError(