Skip to content

Commit 848e651

Browse files
committed
Web TransformStream
1 parent dde6a3b commit 848e651

File tree

4 files changed

+255
-12
lines changed

4 files changed

+255
-12
lines changed

README.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ npm install aline
1818
yarn add aline
1919
```
2020

21-
## Sample
21+
## Sample Node.js Stream Transform
2222
```javascript
23-
const Aline = require('aline');
23+
const {Aline} = require('aline');
2424
const {Readable} = require('stream');
2525

2626
async function* generate() {
@@ -34,3 +34,24 @@ stream.on('data', function(chunk) {
3434
console.log(chunk.toString());
3535
});
3636
```
37+
38+
## Sample Web TransformStream
39+
```javascript
40+
const {AlineWeb} = require('aline');
41+
42+
async function main() {
43+
const response = await fetch('https://example.com/big.jsonl');
44+
45+
if (!response.body) {
46+
return;
47+
}
48+
49+
const readable = response.body
50+
.pipeThrough(new TransformStream(new AlineWeb()))
51+
.pipeThrough(new TextDecoderStream());
52+
53+
for await (const batch of readable) {
54+
const jsonLines = batch.split(/\n/g).map(line => JSON.parse(line));
55+
}
56+
}
57+
```

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
{
22
"name": "aline",
3-
"version": "0.0.9",
3+
"version": "1.0.0",
44
"description": "Align stream chunks to bound of lines",
55
"main": "./dist/index.js",
66
"types": "./dist/index.d.ts",
77
"engines": {
8-
"node": ">=v6.4.0"
8+
"node": ">=v20.0.0"
99
},
1010
"scripts": {
1111
"test": "jest",

src/index.test.ts

Lines changed: 120 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,79 @@
11

2-
import { Readable, Transform } from 'stream';
2+
import { Readable, Transform } from 'node:stream';
33

4-
import Aline from '.'
4+
import { Aline, AlineWeb, concat, lastIndexOf } from '.'
5+
6+
describe('concat two Uint8Array', () => {
7+
test('regular two array', () => {
8+
const left = new Uint8Array([1, 2, 3]);
9+
const right = new Uint8Array([4, 5, 6]);
10+
11+
const combined = Array.from(concat(left, right));
12+
13+
expect(combined).toEqual([1, 2, 3, 4, 5, 6]);
14+
});
15+
16+
test('left array is empty', () => {
17+
const left = new Uint8Array();
18+
const right = new Uint8Array([4, 5, 6]);
19+
20+
const combined = Array.from(concat(left, right));
21+
22+
expect(combined).toEqual([4, 5, 6]);
23+
});
24+
25+
test('right array is empty', () => {
26+
const left = new Uint8Array([1, 2, 3]);
27+
const right = new Uint8Array();
28+
29+
const combined = Array.from(concat(left, right));
30+
31+
expect(combined).toEqual([1, 2, 3]);
32+
});
33+
});
34+
35+
36+
37+
describe('lastIndexOf Uint8Array in Uint8Array', () => {
38+
test('regular two array', () => {
39+
const left = new Uint8Array([1, 2, 3, 4, 5, 6]);
40+
41+
expect(lastIndexOf(left, new Uint8Array([1]))).toBe(0);
42+
43+
expect(lastIndexOf(left, new Uint8Array([1, 2, 3]))).toBe(0);
44+
45+
expect(lastIndexOf(left, new Uint8Array([6]))).toBe(5);
46+
47+
expect(lastIndexOf(left, new Uint8Array([6, 7, 8]))).toBe(-1);
48+
49+
expect(lastIndexOf(left, new Uint8Array([3, 4, 5]))).toBe(2);
50+
51+
expect(lastIndexOf(left, new Uint8Array([3, 4, 5, 6]))).toBe(2);
52+
53+
expect(lastIndexOf(left, new Uint8Array([3, 4, 5, 7]))).toBe(-1);
54+
55+
expect(lastIndexOf(left, new Uint8Array([3, 4]))).toBe(2);
56+
57+
expect(lastIndexOf(left, new Uint8Array([3]))).toBe(2);
58+
59+
expect(lastIndexOf(left, new Uint8Array([3, 5]))).toBe(-1);
60+
61+
expect(lastIndexOf(left, new Uint8Array([7]))).toBe(-1);
62+
63+
expect(lastIndexOf(left, new Uint8Array([4, 5, 6, 7]))).toBe(-1);
64+
});
65+
66+
test('empty array', () => {
67+
const fill = new Uint8Array([1, 2, 3]);
68+
const empty = new Uint8Array();
69+
70+
expect(lastIndexOf(fill, empty)).toBe(-1);
71+
72+
expect(lastIndexOf(empty, fill)).toBe(-1);
73+
74+
expect(lastIndexOf(empty, empty)).toBe(-1);
75+
});
76+
});
577

678

779
function combineData<T extends Transform>(stream: T) {
@@ -62,5 +134,50 @@ const fixture: Array<FixtureItem> = [
62134
['\nfoo\n']]
63135
];
64136

65-
fixture.forEach(([name, generate, target]) => test(name, async() => expect(await combineData(Readable.from(generate()).pipe(new Aline()))).toEqual(target)));
137+
describe('node transform', () => {
138+
fixture.forEach(([name, generate, target]) => test(name, async() => {
139+
expect(await combineData(Readable.from(generate()).pipe(new Aline()))).toEqual(target)
140+
}));
141+
});
142+
143+
144+
function readableStreamFrom<T>(iterable: AsyncIterable<T>) {
145+
return new ReadableStream<T>({
146+
async start(controller): Promise<void> {
147+
for await (const chunk of iterable) {
148+
controller.enqueue(chunk);
149+
}
150+
151+
controller.close();
152+
}
153+
});
154+
}
155+
156+
async function arrayFromAsync<T>(readable: ReadableStream<T>): Promise<Array<T>> {
157+
const chunks: Array<T> = [];
158+
159+
const reader = readable.getReader();
160+
161+
while (true) {
162+
const { done, value } = await reader.read();
163+
if (done) {
164+
break;
165+
}
166+
chunks.push(value);
167+
}
168+
169+
reader.releaseLock();
170+
171+
return chunks;
172+
}
173+
174+
describe('web transform stream', () => {
175+
176+
fixture.forEach(([name, generate, target]) => test(name, async() => {
177+
expect(await arrayFromAsync(readableStreamFrom(generate())
178+
.pipeThrough(new TextEncoderStream())
179+
.pipeThrough(new TransformStream(new AlineWeb()))
180+
.pipeThrough(new TextDecoderStream()))).toEqual(target);
181+
}));
182+
});
66183

src/index.ts

Lines changed: 110 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11

22

3-
import { Transform } from 'stream';
4-
import type { TransformCallback, TransformOptions } from 'stream';
3+
import { Transform } from 'node:stream';
4+
import type { TransformCallback, TransformOptions } from 'node:stream';
55

6-
type AlineOptions = TransformOptions & {separator: string};
6+
type AlineOptions = {separator: string};
77

8-
export default class Aline extends Transform {
8+
export class Aline extends Transform {
99

1010
_tail: Buffer;
1111
_separator: string;
1212

13-
constructor(options?: AlineOptions) {
13+
constructor(options?: TransformOptions & AlineOptions) {
1414
super();
1515

1616
this._tail = Buffer.alloc(0);
@@ -43,3 +43,108 @@ export default class Aline extends Transform {
4343
callback(null, this._tail);
4444
}
4545
}
46+
47+
48+
/**
49+
* Concatenates two Uint8Array instances into a single Uint8Array.
50+
* @param left - The first Uint8Array to concatenate.
51+
* @param right - The second Uint8Array to concatenate.
52+
* @returns A new Uint8Array containing the concatenated elements of the input arrays.
53+
* If one of the input arrays is empty, the function returns the other array unchanged.
54+
* If both input arrays are empty, an empty Uint8Array is returned.
55+
*/
56+
export function concat(left: Uint8Array, right: Uint8Array): Uint8Array {
57+
if (left.length === 0) {
58+
return right;
59+
}
60+
61+
if (right.length === 0) {
62+
return left;
63+
}
64+
65+
const merged = new Uint8Array(left.length + right.length);
66+
merged.set(left);
67+
merged.set(right, left.length);
68+
return merged;
69+
}
70+
71+
/**
72+
* Finds the last occurrence of an exact sequence of bytes (data) within another sequence of bytes (right).
73+
* @param data - The sequence of bytes to search within.
74+
* @param right - The exact sequence of bytes to search for.
75+
* @returns The index of the last occurrence of the specified exact sequence of bytes within the given sequence, or -1 if not found.
76+
*/
77+
export function lastIndexOf(data: Uint8Array, search: Uint8Array): number {
78+
79+
if (search.length === 0 || data.length === 0) {
80+
return -1;
81+
}
82+
83+
const index = data.lastIndexOf(search[0]);
84+
85+
if (index > -1) {
86+
if (data.length - index < search.length) {
87+
return -1;
88+
}
89+
90+
for (let i = 0; i < search.length; i++) {
91+
if (data[index + i] !== search[i]) {
92+
return -1;
93+
}
94+
}
95+
}
96+
97+
return index;
98+
}
99+
100+
101+
export class AlineWeb implements Transformer<Uint8Array, Uint8Array> {
102+
103+
_tail: Uint8Array;
104+
_seperator: Uint8Array;
105+
106+
constructor(options?: AlineOptions) {
107+
const encoder = new TextEncoder();
108+
109+
this._seperator = encoder.encode((options && options.separator) || '\n');
110+
this._tail = new Uint8Array();
111+
}
112+
113+
start() {
114+
this._tail = new Uint8Array();
115+
}
116+
117+
transform(chunk: Uint8Array, controller: TransformStreamDefaultController<Uint8Array>): void {
118+
119+
if (!chunk) {
120+
return;
121+
}
122+
123+
const index = lastIndexOf(chunk, this._seperator);
124+
125+
if (index === -1) {
126+
this._tail = concat(this._tail, chunk);
127+
return;
128+
}
129+
130+
if (index === chunk.length - 1) {
131+
const tail = this._tail;
132+
this._tail = new Uint8Array();
133+
const data = concat(tail, chunk);
134+
data.length > 0 && controller.enqueue(data);
135+
return;
136+
}
137+
138+
const head = concat(this._tail, chunk.slice(0, index + 1));
139+
this._tail = chunk.slice(index + 1);
140+
141+
head.length > 0 && controller.enqueue(head);
142+
}
143+
144+
flush(controller: TransformStreamDefaultController<Uint8Array>): void {
145+
this._tail.length > 0 && controller.enqueue(this._tail);
146+
}
147+
}
148+
149+
150+

0 commit comments

Comments
 (0)