Skip to content

Commit 243e316

Browse files
committed
Add stream & bytes endpoints
1 parent 2679cd9 commit 243e316

File tree

7 files changed

+335
-0
lines changed

7 files changed

+335
-0
lines changed

src/endpoints/http-index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,6 @@ export * from './http/base64.js';
6161
export * from './http/cache.js';
6262
export * from './http/etag.js';
6363
export * from './http/redirect.js';
64+
export * from './http/bytes.js';
65+
export * from './http/stream.js';
66+
export * from './http/stream-bytes.js';

src/endpoints/http/bytes.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import * as crypto from 'crypto';
2+
import { StatusError } from '@httptoolkit/util';
3+
import { HttpEndpoint } from '../http-index.js';
4+
import { httpDynamicData } from '../groups.js';
5+
6+
const MAX_BYTES = 102400; // 100KB
7+
8+
const parseN = (path: string): number => {
9+
const n = parseInt(path.slice('/bytes/'.length), 10);
10+
if (isNaN(n) || n < 0) throw new StatusError(400, `Invalid byte count in ${path}`);
11+
if (n > MAX_BYTES) throw new StatusError(400, `Byte count exceeds maximum of ${MAX_BYTES}`);
12+
return n;
13+
};
14+
15+
export const bytesEndpoint: HttpEndpoint = {
16+
matchPath: (path) => {
17+
if (!path.match(/^\/bytes\/\d+$/)) return false;
18+
parseN(path);
19+
return true;
20+
},
21+
handle: (_req, res, { path, query }) => {
22+
const n = parseN(path);
23+
const seed = query.get('seed');
24+
25+
let data: Buffer;
26+
if (seed !== null) {
27+
// Seeded: use seed to generate deterministic bytes via hash chaining
28+
const chunks: Buffer[] = [];
29+
let remaining = n;
30+
let counter = 0;
31+
while (remaining > 0) {
32+
const hash = crypto.createHash('sha256')
33+
.update(`${seed}:${counter++}`)
34+
.digest();
35+
chunks.push(hash.subarray(0, Math.min(remaining, hash.length)));
36+
remaining -= hash.length;
37+
}
38+
data = Buffer.concat(chunks, n);
39+
} else {
40+
data = crypto.randomBytes(n);
41+
}
42+
43+
res.writeHead(200, {
44+
'content-type': 'application/octet-stream',
45+
'content-length': n.toString()
46+
});
47+
res.end(data);
48+
},
49+
meta: {
50+
path: '/bytes/{n}',
51+
description: 'Returns n random bytes. Supports an optional "seed" query parameter for deterministic output.',
52+
examples: ['/bytes/1024'],
53+
group: httpDynamicData
54+
}
55+
};

src/endpoints/http/stream-bytes.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import * as crypto from 'crypto';
2+
import { StatusError } from '@httptoolkit/util';
3+
import { HttpEndpoint } from '../http-index.js';
4+
import { httpDynamicData } from '../groups.js';
5+
6+
const MAX_BYTES = 102400; // 100KB
7+
8+
const parseN = (path: string): number => {
9+
const n = parseInt(path.slice('/stream-bytes/'.length), 10);
10+
if (isNaN(n) || n < 0) throw new StatusError(400, `Invalid byte count in ${path}`);
11+
if (n > MAX_BYTES) throw new StatusError(400, `Byte count exceeds maximum of ${MAX_BYTES}`);
12+
return n;
13+
};
14+
15+
export const streamBytesEndpoint: HttpEndpoint = {
16+
matchPath: (path) => {
17+
if (!path.match(/^\/stream-bytes\/\d+$/)) return false;
18+
parseN(path);
19+
return true;
20+
},
21+
handle: (_req, res, { path, query }) => {
22+
const n = parseN(path);
23+
const chunkSize = Math.max(1, parseInt(query.get('chunk_size') || '10240', 10));
24+
const seed = query.get('seed');
25+
26+
res.writeHead(200, { 'content-type': 'application/octet-stream' });
27+
28+
let remaining = n;
29+
let counter = 0;
30+
while (remaining > 0) {
31+
const size = Math.min(remaining, chunkSize);
32+
let chunk: Buffer;
33+
34+
if (seed !== null) {
35+
const parts: Buffer[] = [];
36+
let partRemaining = size;
37+
while (partRemaining > 0) {
38+
const hash = crypto.createHash('sha256')
39+
.update(`${seed}:${counter++}`)
40+
.digest();
41+
parts.push(hash.subarray(0, Math.min(partRemaining, hash.length)));
42+
partRemaining -= hash.length;
43+
}
44+
chunk = Buffer.concat(parts, size);
45+
} else {
46+
chunk = crypto.randomBytes(size);
47+
}
48+
49+
(res as NodeJS.WritableStream).write(chunk);
50+
remaining -= size;
51+
}
52+
res.end();
53+
},
54+
meta: {
55+
path: '/stream-bytes/{n}',
56+
description: 'Streams n random bytes in chunks. Supports optional "chunk_size" (default 10240) and "seed" query parameters.',
57+
examples: ['/stream-bytes/1024'],
58+
group: httpDynamicData
59+
}
60+
};

src/endpoints/http/stream.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { StatusError } from '@httptoolkit/util';
2+
import { HttpEndpoint } from '../http-index.js';
3+
import { httpDynamicData } from '../groups.js';
4+
import { buildHttpBinAnythingData } from '../../httpbin-compat.js';
5+
6+
const MAX_LINES = 100;
7+
8+
const parseN = (path: string): number => {
9+
const n = parseInt(path.slice('/stream/'.length), 10);
10+
if (isNaN(n) || n < 0) throw new StatusError(400, `Invalid stream count in ${path}`);
11+
if (n > MAX_LINES) throw new StatusError(400, `Stream count exceeds maximum of ${MAX_LINES}`);
12+
return n;
13+
};
14+
15+
export const streamEndpoint: HttpEndpoint = {
16+
matchPath: (path) => {
17+
if (!path.match(/^\/stream\/\d+$/)) return false;
18+
parseN(path);
19+
return true;
20+
},
21+
handle: async (req, res) => {
22+
const n = parseN(req.url!.split('?')[0]);
23+
const data = await buildHttpBinAnythingData(req, {
24+
fieldFilter: ["url", "args", "headers", "origin"]
25+
});
26+
27+
res.writeHead(200, { 'content-type': 'application/json' });
28+
29+
for (let i = 0; i < n; i++) {
30+
(res as NodeJS.WritableStream).write(JSON.stringify({ id: i, ...data as object }) + '\n');
31+
}
32+
res.end();
33+
},
34+
meta: {
35+
path: '/stream/{n}',
36+
description: 'Streams n newline-delimited JSON objects, each containing request data with an incrementing id.',
37+
examples: ['/stream/5'],
38+
group: httpDynamicData
39+
}
40+
};

test/bytes.spec.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import * as net from 'net';
2+
import { expect } from 'chai';
3+
import { DestroyableServer, makeDestroyable } from 'destroyable-server';
4+
5+
import { createServer } from '../src/server.js';
6+
7+
describe("Bytes endpoint", () => {
8+
9+
let server: DestroyableServer;
10+
let serverPort: number;
11+
12+
beforeEach(async () => {
13+
server = makeDestroyable(await createServer());
14+
await new Promise<void>((resolve) => server.listen(resolve));
15+
serverPort = (server.address() as net.AddressInfo).port;
16+
});
17+
18+
afterEach(async () => {
19+
await server.destroy();
20+
});
21+
22+
it("returns the specified number of random bytes", async () => {
23+
const response = await fetch(`http://localhost:${serverPort}/bytes/256`);
24+
expect(response.status).to.equal(200);
25+
expect(response.headers.get('content-type')).to.equal('application/octet-stream');
26+
expect(response.headers.get('content-length')).to.equal('256');
27+
const body = await response.arrayBuffer();
28+
expect(body.byteLength).to.equal(256);
29+
});
30+
31+
it("returns zero bytes for /bytes/0", async () => {
32+
const response = await fetch(`http://localhost:${serverPort}/bytes/0`);
33+
expect(response.status).to.equal(200);
34+
const body = await response.arrayBuffer();
35+
expect(body.byteLength).to.equal(0);
36+
});
37+
38+
it("returns deterministic output with seed", async () => {
39+
const r1 = await fetch(`http://localhost:${serverPort}/bytes/64?seed=42`);
40+
const r2 = await fetch(`http://localhost:${serverPort}/bytes/64?seed=42`);
41+
const b1 = Buffer.from(await r1.arrayBuffer());
42+
const b2 = Buffer.from(await r2.arrayBuffer());
43+
expect(b1.equals(b2)).to.be.true;
44+
});
45+
46+
it("returns different output with different seeds", async () => {
47+
const r1 = await fetch(`http://localhost:${serverPort}/bytes/64?seed=1`);
48+
const r2 = await fetch(`http://localhost:${serverPort}/bytes/64?seed=2`);
49+
const b1 = Buffer.from(await r1.arrayBuffer());
50+
const b2 = Buffer.from(await r2.arrayBuffer());
51+
expect(b1.equals(b2)).to.be.false;
52+
});
53+
54+
it("rejects byte counts over the maximum", async () => {
55+
const response = await fetch(`http://localhost:${serverPort}/bytes/200000`);
56+
expect(response.status).to.equal(400);
57+
});
58+
});

test/stream-bytes.spec.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import * as net from 'net';
2+
import { expect } from 'chai';
3+
import { DestroyableServer, makeDestroyable } from 'destroyable-server';
4+
5+
import { createServer } from '../src/server.js';
6+
7+
describe("Stream-bytes endpoint", () => {
8+
9+
let server: DestroyableServer;
10+
let serverPort: number;
11+
12+
beforeEach(async () => {
13+
server = makeDestroyable(await createServer());
14+
await new Promise<void>((resolve) => server.listen(resolve));
15+
serverPort = (server.address() as net.AddressInfo).port;
16+
});
17+
18+
afterEach(async () => {
19+
await server.destroy();
20+
});
21+
22+
it("streams the specified number of bytes", async () => {
23+
const response = await fetch(`http://localhost:${serverPort}/stream-bytes/512`);
24+
expect(response.status).to.equal(200);
25+
expect(response.headers.get('content-type')).to.equal('application/octet-stream');
26+
const body = await response.arrayBuffer();
27+
expect(body.byteLength).to.equal(512);
28+
});
29+
30+
it("returns zero bytes for /stream-bytes/0", async () => {
31+
const response = await fetch(`http://localhost:${serverPort}/stream-bytes/0`);
32+
expect(response.status).to.equal(200);
33+
const body = await response.arrayBuffer();
34+
expect(body.byteLength).to.equal(0);
35+
});
36+
37+
it("returns deterministic output with seed", async () => {
38+
const r1 = await fetch(`http://localhost:${serverPort}/stream-bytes/128?seed=abc`);
39+
const r2 = await fetch(`http://localhost:${serverPort}/stream-bytes/128?seed=abc`);
40+
const b1 = Buffer.from(await r1.arrayBuffer());
41+
const b2 = Buffer.from(await r2.arrayBuffer());
42+
expect(b1.equals(b2)).to.be.true;
43+
});
44+
45+
it("respects custom chunk_size", async () => {
46+
// Just verify it works — we can't easily inspect chunking from fetch,
47+
// but we can confirm the total byte count is correct
48+
const response = await fetch(
49+
`http://localhost:${serverPort}/stream-bytes/100?chunk_size=10`
50+
);
51+
expect(response.status).to.equal(200);
52+
const body = await response.arrayBuffer();
53+
expect(body.byteLength).to.equal(100);
54+
});
55+
56+
it("rejects byte counts over the maximum", async () => {
57+
const response = await fetch(`http://localhost:${serverPort}/stream-bytes/200000`);
58+
expect(response.status).to.equal(400);
59+
});
60+
});

test/stream.spec.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import * as net from 'net';
2+
import { expect } from 'chai';
3+
import { DestroyableServer, makeDestroyable } from 'destroyable-server';
4+
5+
import { createServer } from '../src/server.js';
6+
7+
describe("Stream endpoint", () => {
8+
9+
let server: DestroyableServer;
10+
let serverPort: number;
11+
12+
beforeEach(async () => {
13+
server = makeDestroyable(await createServer());
14+
await new Promise<void>((resolve) => server.listen(resolve));
15+
serverPort = (server.address() as net.AddressInfo).port;
16+
});
17+
18+
afterEach(async () => {
19+
await server.destroy();
20+
});
21+
22+
it("streams n JSON lines", async () => {
23+
const response = await fetch(`http://localhost:${serverPort}/stream/3`);
24+
expect(response.status).to.equal(200);
25+
expect(response.headers.get('content-type')).to.equal('application/json');
26+
27+
const body = await response.text();
28+
const lines = body.trim().split('\n');
29+
expect(lines).to.have.length(3);
30+
31+
const parsed = lines.map(l => JSON.parse(l));
32+
expect(parsed[0].id).to.equal(0);
33+
expect(parsed[1].id).to.equal(1);
34+
expect(parsed[2].id).to.equal(2);
35+
});
36+
37+
it("includes request data fields in each line", async () => {
38+
const response = await fetch(`http://localhost:${serverPort}/stream/1`);
39+
const body = await response.text();
40+
const obj = JSON.parse(body.trim());
41+
42+
expect(obj).to.have.property('id', 0);
43+
expect(obj).to.have.property('url');
44+
expect(obj).to.have.property('headers');
45+
expect(obj).to.have.property('origin');
46+
});
47+
48+
it("returns empty body for n=0", async () => {
49+
const response = await fetch(`http://localhost:${serverPort}/stream/0`);
50+
expect(response.status).to.equal(200);
51+
const body = await response.text();
52+
expect(body).to.equal('');
53+
});
54+
55+
it("rejects n over maximum", async () => {
56+
const response = await fetch(`http://localhost:${serverPort}/stream/200`);
57+
expect(response.status).to.equal(400);
58+
});
59+
});

0 commit comments

Comments
 (0)