-
Notifications
You must be signed in to change notification settings - Fork 678
Expand file tree
/
Copy pathtext_line_stream.ts
More file actions
147 lines (137 loc) · 4.43 KB
/
Copy pathtext_line_stream.ts
File metadata and controls
147 lines (137 loc) · 4.43 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
// Copyright 2018-2026 the Deno authors. MIT license.
// This module is browser compatible.
/** Options for {@linkcode TextLineStream}. */
export interface TextLineStreamOptions {
/**
* Allow splitting by `\r`.
*
* @default {false}
*/
allowCR?: boolean;
}
/**
* Transform a stream into a stream where each chunk is divided by a newline,
* be it `\n` or `\r\n`. `\r` can be enabled via the `allowCR` option.
*
* If you want to split by a custom delimiter, consider using {@linkcode TextDelimiterStream}.
*
* @example JSON Lines
* ```ts
* import { TextLineStream } from "@std/streams/text-line-stream";
* import { toTransformStream } from "@std/streams/to-transform-stream";
* import { assertEquals } from "@std/assert";
*
* const stream = ReadableStream.from([
* '{"name": "Alice", "age": ',
* '30}\n{"name": "Bob", "age"',
* ": 25}\n",
* ]);
*
* type Person = { name: string; age: number };
*
* // Split the stream by newline and parse each line as a JSON object
* const jsonStream = stream.pipeThrough(new TextLineStream())
* .pipeThrough(toTransformStream(async function* (src) {
* for await (const chunk of src) {
* if (chunk.trim().length === 0) {
* continue;
* }
* yield JSON.parse(chunk) as Person;
* }
* }));
*
* assertEquals(
* await Array.fromAsync(jsonStream),
* [{ "name": "Alice", "age": 30 }, { "name": "Bob", "age": 25 }],
* );
* ```
*
* @example Allow splitting by `\r`
*
* ```ts
* import { TextLineStream } from "@std/streams/text-line-stream";
* import { assertEquals } from "@std/assert";
*
* const stream = ReadableStream.from([
* "CR\rLF",
* "\nCRLF\r\ndone",
* ]).pipeThrough(new TextLineStream({ allowCR: true }));
*
* const lines = await Array.fromAsync(stream);
*
* assertEquals(lines, ["CR", "LF", "CRLF", "done"]);
* ```
*/
export class TextLineStream extends TransformStream<string, string> {
#pending: string[] = [];
#pendingTrailsCR = false;
/**
* Constructs a new instance.
*
* @param options Options for the stream.
*/
constructor(options: TextLineStreamOptions = { allowCR: false }) {
const allowCR = options.allowCR ?? false;
super({
transform: (chars, controller) => {
if (chars.length === 0) return;
// Fast path: if no line can complete within this chunk, buffer it
// without rescanning the accumulated text. This keeps the total cost
// linear when a single line spans many chunks. (With `allowCR`, a
// buffered chunk-final "\r" resolves as soon as the next chunk
// arrives, so it forces processing.)
if (
!chars.includes("\n") &&
(!allowCR || (!this.#pendingTrailsCR && !chars.includes("\r")))
) {
this.#pending.push(chars);
return;
}
this.#pending.push(chars);
const text = this.#pending.length === 1
? this.#pending[0]!
: this.#pending.join("");
this.#pending.length = 0;
let start = 0;
let lfIndex = text.indexOf("\n");
let crIndex = allowCR ? text.indexOf("\r") : -1;
while (true) {
if (
crIndex !== -1 && crIndex !== (text.length - 1) &&
(lfIndex === -1 || (lfIndex - 1) > crIndex)
) {
controller.enqueue(text.slice(start, crIndex));
start = crIndex + 1;
crIndex = text.indexOf("\r", start);
continue;
}
if (lfIndex === -1) break;
const endIndex = text[lfIndex - 1] === "\r" ? lfIndex - 1 : lfIndex;
controller.enqueue(text.slice(start, endIndex));
start = lfIndex + 1;
lfIndex = text.indexOf("\n", start);
if (crIndex !== -1 && crIndex < start) {
crIndex = text.indexOf("\r", start);
}
}
if (start < text.length) {
const leftover = start === 0 ? text : text.slice(start);
this.#pending.push(leftover);
this.#pendingTrailsCR = allowCR && leftover.endsWith("\r");
} else {
this.#pendingTrailsCR = false;
}
},
flush: (controller) => {
if (this.#pending.length === 0) return;
let currentLine = this.#pending.length === 1
? this.#pending[0]!
: this.#pending.join("");
if (allowCR && currentLine.endsWith("\r")) {
currentLine = currentLine.slice(0, -1);
}
controller.enqueue(currentLine);
},
});
}
}