@johnhenry/letterpress 0.0.3 → 0.2.0

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.
@@ -0,0 +1,164 @@
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
+ export const parseHttpText = (strings, substitutions, options = {}) => {
43
+ const { isStartLine = () => true, trimBodyLines = false } = options;
44
+
45
+ const stringsArr = [...strings];
46
+ stringsArr[0] = stringsArr[0].replace(/^[ \t\r\n]+/, "");
47
+ stringsArr[stringsArr.length - 1] = stringsArr[stringsArr.length - 1].replace(
48
+ /[ \t\r\n]+$/,
49
+ ""
50
+ );
51
+
52
+ let zone = "start"; // "start" -> "headers" -> "body"
53
+ let lineBuffer = "";
54
+ // A binary substitution can't be classified as "body content" the
55
+ // moment it's seen while zone is "start" -- that line might turn out to
56
+ // be a real start line (invalid) or, once nothing else precedes it,
57
+ // the first line of a body-only template (valid, same as it landing in
58
+ // the body zone directly). Held here until the line is flushed and its
59
+ // true zone is known.
60
+ let pendingBinary = null;
61
+ let startLine = null;
62
+ const headerEntries = []; // ordered [{ name, value }], duplicates allowed
63
+ let bodyText = "";
64
+ let binaryBody = null;
65
+ let binaryBodyCount = 0;
66
+ let bodyHasOtherContent = false;
67
+
68
+ const commitBodyLine = (rawLine, isFinal) => {
69
+ const line = trimBodyLines ? rawLine.trim() : rawLine;
70
+ if (pendingBinary) {
71
+ if (line.trim() !== "" || bodyHasOtherContent) {
72
+ throw new TypeError(
73
+ "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"
74
+ );
75
+ }
76
+ binaryBodyCount++;
77
+ binaryBody = pendingBinary;
78
+ pendingBinary = null;
79
+ } else {
80
+ bodyText += isFinal ? line : line + "\n";
81
+ if (line.trim() !== "") bodyHasOtherContent = true;
82
+ }
83
+ };
84
+
85
+ const flushLine = () => {
86
+ if (zone === "start") {
87
+ if (isStartLine(lineBuffer)) {
88
+ if (pendingBinary) {
89
+ throw new TypeError(
90
+ "parseHttpText: a binary substitution cannot appear within the start line"
91
+ );
92
+ }
93
+ startLine = lineBuffer;
94
+ zone = "headers";
95
+ } else {
96
+ zone = "body";
97
+ commitBodyLine(lineBuffer, false);
98
+ }
99
+ } else if (zone === "headers") {
100
+ if (pendingBinary) {
101
+ throw new TypeError(
102
+ "parseHttpText: a binary substitution cannot appear within a header line"
103
+ );
104
+ }
105
+ if (lineBuffer.trim() === "") {
106
+ zone = "body";
107
+ } else {
108
+ const colonIndex = lineBuffer.indexOf(":");
109
+ if (colonIndex > 0) {
110
+ headerEntries.push({
111
+ name: lineBuffer.slice(0, colonIndex).trim(),
112
+ value: lineBuffer.slice(colonIndex + 1).trim(),
113
+ });
114
+ }
115
+ }
116
+ }
117
+ lineBuffer = "";
118
+ };
119
+
120
+ for (let i = 0; i < stringsArr.length; i++) {
121
+ const parts = stringsArr[i].split("\n");
122
+ for (let p = 0; p < parts.length; p++) {
123
+ if (p > 0) {
124
+ if (zone === "body") {
125
+ commitBodyLine(lineBuffer, false);
126
+ } else {
127
+ flushLine();
128
+ }
129
+ }
130
+ lineBuffer += parts[p];
131
+ }
132
+
133
+ if (i < substitutions.length) {
134
+ const sub = substitutions[i];
135
+ if (sub instanceof Headers) {
136
+ for (const [name, value] of sub) headerEntries.push({ name, value });
137
+ } else if (zone !== "headers" && BINARY_BODY_TYPES(sub)) {
138
+ if (pendingBinary) {
139
+ throw new TypeError(
140
+ "parseHttpText: more than one binary body substitution found -- a binary value must be the sole content of the body"
141
+ );
142
+ }
143
+ pendingBinary = sub;
144
+ } else if (sub !== undefined && sub !== null) {
145
+ if (zone === "body") bodyHasOtherContent = true;
146
+ lineBuffer += String(sub);
147
+ }
148
+ }
149
+ }
150
+
151
+ if (zone === "body") {
152
+ commitBodyLine(lineBuffer, true);
153
+ } else {
154
+ flushLine();
155
+ }
156
+
157
+ return {
158
+ startLine,
159
+ headerEntries,
160
+ body: binaryBodyCount === 1 ? binaryBody : bodyText.trim(),
161
+ };
162
+ };
163
+
164
+ export default parseHttpText;