@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 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 or a `tagRequest` 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` from a template literal, use `createRequest` (see below).
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
- Tagged-template functions for building `Request`/`Response` objects from raw HTTP-message-shaped template literals. See [api.md](./api.md) for details.
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 for the browser.
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
- const processInput = async () => {
33
- let mode = "INITIAL";
34
- let buffer = "";
35
- let bodyContent = "";
36
- let headerName = "";
37
- let i = 0;
38
- let j = 0;
39
-
40
- const processChunk = (chunk) => {
41
- buffer += chunk;
42
- const lines = buffer.split("\n");
43
-
44
- while (lines.length > 1) {
45
- const line = lines.shift().trim();
46
-
47
- switch (mode) {
48
- case "INITIAL":
49
- if (line.startsWith("HTTP/")) {
50
- const [httpVersion, statusCode, ...statusTextParts] =
51
- line.split(" ");
52
- status = parseInt(statusCode, 10);
53
- statusText = statusTextParts.join(" ");
54
- mode = "HEADER";
55
- } else if (line !== "") {
56
- mode = "BODY";
57
- bodyContent += line + "\n";
58
- }
59
- break;
60
-
61
- case "HEADER":
62
- if (line === "") {
63
- mode = "BODY";
64
- } else {
65
- const colonIndex = line.indexOf(":");
66
- if (colonIndex > 0) {
67
- const key = line.slice(0, colonIndex).trim();
68
- const value = line.slice(colonIndex + 1).trim();
69
- headers.set(key, value);
70
- }
71
- }
72
- break;
73
-
74
- case "BODY":
75
- bodyContent += line + "\n";
76
- break;
77
- }
78
- }
79
-
80
- buffer = lines[0] || "";
81
- };
82
-
83
- while (i < strings.length) {
84
- processChunk(strings[i]);
85
-
86
- if (j < substitutions.length) {
87
- const sub = substitutions[j];
88
- if (typeof sub === "function") {
89
- const result = await sub(request, context);
90
- if (
91
- result instanceof ReadableStream ||
92
- result instanceof Blob ||
93
- result instanceof ArrayBuffer ||
94
- result instanceof Uint8Array
95
- ) {
96
- // A substitution function can return a stream/binary value
97
- // just like a directly-substituted one; it must get the
98
- // same raw-passthrough treatment rather than being coerced
99
- // via .toString() (which would produce "[object ...]").
100
- if (!headers.has("Content-Type")) {
101
- headers.set(
102
- "Content-Type",
103
- result.type ?? "application/octet-stream" // Blob may have a type
104
- );
105
- }
106
- return result;
107
- } else if (result !== undefined && result !== null) {
108
- processChunk(result.toString());
109
- }
110
- } else if (
111
- sub instanceof ReadableStream ||
112
- sub instanceof Blob ||
113
- sub instanceof ArrayBuffer ||
114
- sub instanceof Uint8Array
115
- ) {
116
- if (!headers.has("Content-Type")) {
117
- headers.set(
118
- "Content-Type",
119
- sub.type ?? "application/octet-stream" // Blob may have a type
120
- );
121
- }
122
- return sub;
123
- } else if (sub instanceof Headers) {
124
- for (const [key, value] of sub) {
125
- headers.set(key, value);
126
- }
127
- } else if (sub !== undefined && sub !== null) {
128
- processChunk(sub.toString());
129
- }
130
- j++;
131
- }
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
- let responseBody = await processInput();
145
-
146
- if (!headers.has("Content-Type")) {
147
- headers.set("Content-Type", "text/html");
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
- if (typeof responseBody === "string") {
151
- try {
152
- JSON.parse(responseBody);
153
- if (!headers.has("Content-Type")) {
154
- headers.set("Content-Type", "application/json");
155
- }
156
- } catch {
157
- // Not JSON, keep existing Content-Type
158
- }
78
+ let responseBody = body;
79
+ const isBinaryBody = typeof responseBody !== "string";
159
80
 
160
- if (!headers.has("Content-Length")) {
81
+ if (isBinaryBody) {
82
+ if (!headers.has("Content-Type")) {
161
83
  headers.set(
162
- "Content-Length",
163
- new Blob([responseBody]).size.toString()
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.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",
@@ -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;
@@ -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
@@ -2,6 +2,7 @@ import "./router.mjs";
2
2
  import "./http-expression.mjs";
3
3
  import "./route.mjs";
4
4
  import "./req-res.mjs";
5
+ import "./tag-req-res.mjs";
5
6
  import "./fs-router.mjs";
6
7
  import "./rewrite.mjs";
7
8
  import "./dynamic-run.mjs";
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
+ });
@@ -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;