kikoeru 0.1.1 → 0.1.2
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/README.md +44 -44
- package/bin/{koed.js → kikoeru.js} +1 -1
- package/package.json +1 -1
- package/src/server.js +8 -8
- package/test/cache.test.js +1 -1
- package/test/chunking.test.js +1 -1
- package/test/config.test.js +5 -5
- package/test/helpers.js +2 -2
- package/test/upstream.test.js +7 -7
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
[](https://badge.fury.io/js/kikoeru)
|
|
2
2
|
|
|
3
|
-
#
|
|
3
|
+
# kikoeru
|
|
4
4
|
|
|
5
5
|
A small server that relays YouTube audio. It fetches the sound in
|
|
6
6
|
small pieces and passes the pieces to the listener right away, so
|
|
@@ -22,23 +22,23 @@ problem:
|
|
|
22
22
|
- Asking for small parts, one after another, stays fast to the end.
|
|
23
23
|
In our tests, a 16.6 MB file arrived in about 1.7 seconds this way.
|
|
24
24
|
|
|
25
|
-
A phone browser cannot be told to fetch in small parts. So
|
|
26
|
-
in the middle: the phone asks
|
|
25
|
+
A phone browser cannot be told to fetch in small parts. So kikoeru sits
|
|
26
|
+
in the middle: the phone asks kikoeru, and kikoeru asks YouTube in small
|
|
27
27
|
parts.
|
|
28
28
|
|
|
29
|
-
## What
|
|
29
|
+
## What kikoeru does
|
|
30
30
|
|
|
31
31
|
- It listens for requests of one form: `GET /<token>/<videoId>`.
|
|
32
32
|
- It looks up the video's sound address in a cache file (see below).
|
|
33
33
|
- It asks YouTube for the file in small parts of at most 4 MiB
|
|
34
34
|
(4,194,304 bytes) and sends each part to the listener as soon as
|
|
35
35
|
it arrives.
|
|
36
|
-
- It saves nothing. No sound file is ever written to disk.
|
|
36
|
+
- It saves nothing. No sound file is ever written to disk. kikoeru is a
|
|
37
37
|
relay, not a file host.
|
|
38
38
|
|
|
39
|
-
## What
|
|
39
|
+
## What kikoeru does not do
|
|
40
40
|
|
|
41
|
-
Finding the sound address is **not**
|
|
41
|
+
Finding the sound address is **not** kikoeru's job. A shell pipeline
|
|
42
42
|
(`yt-audio` and `yt-audio-batch`, which run `yt-dlp`) finds the
|
|
43
43
|
addresses and writes them to a cache file:
|
|
44
44
|
|
|
@@ -46,7 +46,7 @@ addresses and writes them to a cache file:
|
|
|
46
46
|
~/.yt-audio/cache.json
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
kikoeru only reads this file. For each video, the file holds the sound
|
|
50
50
|
address and the time it was found:
|
|
51
51
|
|
|
52
52
|
```json
|
|
@@ -60,20 +60,20 @@ address and the time it was found:
|
|
|
60
60
|
|
|
61
61
|
The rules are simple:
|
|
62
62
|
|
|
63
|
-
- Entry exists and is less than 5 hours old →
|
|
64
|
-
- Entry is missing or older than 5 hours →
|
|
63
|
+
- Entry exists and is less than 5 hours old → kikoeru relays the sound.
|
|
64
|
+
- Entry is missing or older than 5 hours → kikoeru answers 404.
|
|
65
65
|
(The shell pipeline will find a fresh address on its next run.)
|
|
66
66
|
|
|
67
67
|
Sound addresses stop working after some hours. If YouTube answers
|
|
68
|
-
403 ("forbidden") while
|
|
68
|
+
403 ("forbidden") while kikoeru is working, kikoeru reads the cache file
|
|
69
69
|
one more time — the shell pipeline may have put a fresh address in
|
|
70
|
-
it — and tries once more. If the address still does not work,
|
|
70
|
+
it — and tries once more. If the address still does not work, kikoeru
|
|
71
71
|
answers 404.
|
|
72
72
|
|
|
73
|
-
If YouTube fails while the sound is being sent,
|
|
74
|
-
the listener waiting. When no sound has been sent yet,
|
|
73
|
+
If YouTube fails while the sound is being sent, kikoeru never leaves
|
|
74
|
+
the listener waiting. When no sound has been sent yet, kikoeru answers
|
|
75
75
|
502 ("bad gateway") at once. When some sound has already been sent,
|
|
76
|
-
|
|
76
|
+
kikoeru ends the connection at once, so the player can show an error
|
|
77
77
|
instead of waiting for ever.
|
|
78
78
|
|
|
79
79
|
Built for the [reader](https://github.com/freddiefujiwara/reader)
|
|
@@ -82,32 +82,32 @@ web RSS reader, which plays YouTube videos like podcasts.
|
|
|
82
82
|
## Requirements
|
|
83
83
|
|
|
84
84
|
- Node.js 18 or later
|
|
85
|
-
- No extra packages.
|
|
85
|
+
- No extra packages. kikoeru uses only the Node.js standard library.
|
|
86
86
|
|
|
87
87
|
## Installation
|
|
88
88
|
|
|
89
89
|
From npm:
|
|
90
90
|
|
|
91
91
|
```bash
|
|
92
|
-
npm i -g
|
|
92
|
+
npm i -g kikoeru
|
|
93
93
|
```
|
|
94
94
|
|
|
95
95
|
Or from a tarball built from this repository:
|
|
96
96
|
|
|
97
97
|
```bash
|
|
98
|
-
npm pack # makes
|
|
99
|
-
npm i -g ./
|
|
98
|
+
npm pack # makes kikoeru-0.1.2.tgz
|
|
99
|
+
npm i -g ./kikoeru-0.1.2.tgz
|
|
100
100
|
```
|
|
101
101
|
|
|
102
102
|
With mise's Node on our home server, the global install goes to the
|
|
103
103
|
Node directory itself, so the module lands in
|
|
104
|
-
`~/.local/share/mise/installs/node/<version>/lib/node_modules/
|
|
105
|
-
and the `
|
|
104
|
+
`~/.local/share/mise/installs/node/<version>/lib/node_modules/kikoeru`
|
|
105
|
+
and the `kikoeru` command in the `bin/` folder next to it.
|
|
106
106
|
|
|
107
107
|
## Usage
|
|
108
108
|
|
|
109
109
|
```bash
|
|
110
|
-
|
|
110
|
+
kikoeru
|
|
111
111
|
```
|
|
112
112
|
|
|
113
113
|
This starts the server on `127.0.0.1:8791`. It only listens on the
|
|
@@ -139,11 +139,11 @@ Range: bytes=1000000-
|
|
|
139
139
|
Range: bytes=-1000
|
|
140
140
|
```
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
kikoeru answers `206` with exactly those bytes and a `Content-Range`
|
|
143
143
|
header. The parts are still fetched from YouTube in pieces of at
|
|
144
144
|
most 4 MiB. If the part cannot be given (for example, it starts
|
|
145
|
-
after the end of the file),
|
|
146
|
-
that
|
|
145
|
+
after the end of the file), kikoeru answers `416`. A `Range` header
|
|
146
|
+
that kikoeru cannot read is ignored, and the whole file is sent.
|
|
147
147
|
`If-Range` is ignored.
|
|
148
148
|
|
|
149
149
|
## The token
|
|
@@ -151,14 +151,14 @@ that koed cannot read is ignored, and the whole file is sent.
|
|
|
151
151
|
The token lives in a file:
|
|
152
152
|
|
|
153
153
|
```text
|
|
154
|
-
~/.config/
|
|
154
|
+
~/.config/kikoeru/token
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
-
- If the file exists,
|
|
158
|
-
- If the file does not exist,
|
|
157
|
+
- If the file exists, kikoeru reads the token from it.
|
|
158
|
+
- If the file does not exist, kikoeru makes a random token (32 hex
|
|
159
159
|
digits) on the first start, saves it to the file with permission
|
|
160
160
|
`600` (only the owner can read it), and uses it from then on.
|
|
161
|
-
- You can also set the token with the `
|
|
161
|
+
- You can also set the token with the `KIKOERU_TOKEN` environment
|
|
162
162
|
variable (see below). Then the file is not used.
|
|
163
163
|
|
|
164
164
|
Anyone who knows the token and a video id can listen through your
|
|
@@ -171,10 +171,10 @@ environment variable.
|
|
|
171
171
|
|
|
172
172
|
| Variable | Default | Meaning |
|
|
173
173
|
| --- | --- | --- |
|
|
174
|
-
| `
|
|
175
|
-
| `
|
|
176
|
-
| `
|
|
177
|
-
| `
|
|
174
|
+
| `KIKOERU_HOST` | `127.0.0.1` | Address to listen on |
|
|
175
|
+
| `KIKOERU_PORT` | `8791` | Port to listen on |
|
|
176
|
+
| `KIKOERU_TOKEN` | (contents of the token file) | The shared secret |
|
|
177
|
+
| `KIKOERU_TOKEN_FILE` | `~/.config/kikoeru/token` | Where the token is kept |
|
|
178
178
|
| `YT_AUDIO_CACHE` | `~/.yt-audio/cache.json` | The shell pipeline's cache file |
|
|
179
179
|
|
|
180
180
|
## Example
|
|
@@ -182,8 +182,8 @@ environment variable.
|
|
|
182
182
|
Start the server, read the token, and ask for a video:
|
|
183
183
|
|
|
184
184
|
```bash
|
|
185
|
-
|
|
186
|
-
TOKEN=$(cat ~/.config/
|
|
185
|
+
kikoeru &
|
|
186
|
+
TOKEN=$(cat ~/.config/kikoeru/token)
|
|
187
187
|
curl -o sound.m4a "http://127.0.0.1:8791/$TOKEN/htzMcSM6Ft0"
|
|
188
188
|
```
|
|
189
189
|
|
|
@@ -203,17 +203,17 @@ curl -i "http://127.0.0.1:8791/wrong-token/htzMcSM6Ft0"
|
|
|
203
203
|
|
|
204
204
|
## Run it as a service (systemd)
|
|
205
205
|
|
|
206
|
-
On our home server,
|
|
207
|
-
file is `~/.config/systemd/user/
|
|
206
|
+
On our home server, kikoeru runs as a systemd user service. The unit
|
|
207
|
+
file is `~/.config/systemd/user/kikoeru.service`:
|
|
208
208
|
|
|
209
209
|
```ini
|
|
210
210
|
[Unit]
|
|
211
|
-
Description=
|
|
211
|
+
Description=kikoeru (YouTube audio streaming relay)
|
|
212
212
|
After=network-online.target
|
|
213
213
|
Wants=network-online.target
|
|
214
214
|
|
|
215
215
|
[Service]
|
|
216
|
-
ExecStart=/home/fumikazu/.local/share/mise/installs/node/24.14.1/bin/node /home/fumikazu/.local/share/mise/installs/node/24.14.1/lib/node_modules/
|
|
216
|
+
ExecStart=/home/fumikazu/.local/share/mise/installs/node/24.14.1/bin/node /home/fumikazu/.local/share/mise/installs/node/24.14.1/lib/node_modules/kikoeru/bin/kikoeru.js
|
|
217
217
|
Restart=on-failure
|
|
218
218
|
RestartSec=3
|
|
219
219
|
|
|
@@ -225,7 +225,7 @@ Change the Node version in the paths when Node is upgraded. Then:
|
|
|
225
225
|
|
|
226
226
|
```bash
|
|
227
227
|
systemctl --user daemon-reload
|
|
228
|
-
systemctl --user enable --now
|
|
228
|
+
systemctl --user enable --now kikoeru
|
|
229
229
|
```
|
|
230
230
|
|
|
231
231
|
If user lingering is on (`loginctl enable-linger`), the service
|
|
@@ -233,9 +233,9 @@ starts at boot and keeps running after logout.
|
|
|
233
233
|
|
|
234
234
|
## Reverse proxy
|
|
235
235
|
|
|
236
|
-
|
|
236
|
+
kikoeru only listens on the local machine, so we publish it with nginx
|
|
237
237
|
on its own host name. In this example the host is `y.ze.gs` and the
|
|
238
|
-
whole site is passed to
|
|
238
|
+
whole site is passed to kikoeru:
|
|
239
239
|
|
|
240
240
|
```nginx
|
|
241
241
|
server {
|
|
@@ -290,4 +290,4 @@ server on the local machine, so no network is needed. They cover:
|
|
|
290
290
|
|
|
291
291
|
## Contributing
|
|
292
292
|
|
|
293
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/freddiefujiwara/
|
|
293
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/freddiefujiwara/kikoeru
|
|
@@ -7,6 +7,6 @@ const cfg = loadConfig();
|
|
|
7
7
|
const server = createServer(cfg);
|
|
8
8
|
server.listen(cfg.port, cfg.host, () => {
|
|
9
9
|
console.log(
|
|
10
|
-
`[
|
|
10
|
+
`[kikoeru] listening on http://${cfg.host}:${cfg.port}/<token>/<videoId>`,
|
|
11
11
|
);
|
|
12
12
|
});
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"bin":{"
|
|
1
|
+
{"bin":{"kikoeru":"bin/kikoeru.js"},"description":"Streaming relay for YouTube audio: fetches googlevideo streams with small ranged requests and forwards them to the client without storing any files.","engines":{"node":">=18"},"license":"MIT","main":"src/server.js","name":"kikoeru","scripts":{"start":"node bin/kikoeru.js","test":"node --test \"test/**/*.test.js\""},"version":"0.1.2"}
|
package/src/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* kikoeru: a streaming relay for YouTube audio.
|
|
5
5
|
*
|
|
6
6
|
* Clients (e.g. a phone browser) that play a googlevideo audio URL directly
|
|
7
7
|
* are throttled to roughly the playback bitrate when they fetch the file
|
|
@@ -44,11 +44,11 @@ const ROUTE_RE = /^\/([^/]+)\/([^/]+)$/;
|
|
|
44
44
|
function loadConfig(env = process.env) {
|
|
45
45
|
const home = os.homedir();
|
|
46
46
|
return {
|
|
47
|
-
host: env.
|
|
48
|
-
port: Number(env.
|
|
49
|
-
token: env.
|
|
47
|
+
host: env.KIKOERU_HOST || '127.0.0.1',
|
|
48
|
+
port: Number(env.KIKOERU_PORT || 8791),
|
|
49
|
+
token: env.KIKOERU_TOKEN || null,
|
|
50
50
|
tokenFile:
|
|
51
|
-
env.
|
|
51
|
+
env.KIKOERU_TOKEN_FILE || path.join(home, '.config/kikoeru/token'),
|
|
52
52
|
batchCacheFile:
|
|
53
53
|
env.YT_AUDIO_CACHE || path.join(home, '.yt-audio/cache.json'),
|
|
54
54
|
};
|
|
@@ -212,7 +212,7 @@ async function handle(cfg, state, token, req, res) {
|
|
|
212
212
|
try {
|
|
213
213
|
meta = await getMeta(cfg, state, videoId, retried);
|
|
214
214
|
} catch (e) {
|
|
215
|
-
console.error(`[
|
|
215
|
+
console.error(`[kikoeru] meta failed for ${videoId}: ${e.message}`);
|
|
216
216
|
meta = null;
|
|
217
217
|
}
|
|
218
218
|
if (!meta) {
|
|
@@ -286,7 +286,7 @@ async function handle(cfg, state, token, req, res) {
|
|
|
286
286
|
continue;
|
|
287
287
|
}
|
|
288
288
|
if (e.code !== 'UPSTREAM_EXPIRED') {
|
|
289
|
-
console.error(`[
|
|
289
|
+
console.error(`[kikoeru] stream failed for ${videoId}: ${e.message}`);
|
|
290
290
|
}
|
|
291
291
|
if (!tracker.committed && !res.headersSent && !res.writableEnded) {
|
|
292
292
|
// Nothing has been sent to the client yet, so the failure can
|
|
@@ -341,7 +341,7 @@ function createServer(cfg) {
|
|
|
341
341
|
const state = { meta: new Map() };
|
|
342
342
|
const server = http.createServer((req, res) => {
|
|
343
343
|
handle(cfg, state, token, req, res).catch((e) => {
|
|
344
|
-
console.error(`[
|
|
344
|
+
console.error(`[kikoeru] handler error: ${e.message}`);
|
|
345
345
|
if (!res.headersSent) {
|
|
346
346
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
347
347
|
}
|
package/test/cache.test.js
CHANGED
|
@@ -43,7 +43,7 @@ after(async () => {
|
|
|
43
43
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
-
// Start a throwaway
|
|
46
|
+
// Start a throwaway kikoeru whose batch cache is `cacheFile`, run `fn`
|
|
47
47
|
// against its port, and always close it.
|
|
48
48
|
async function withServer(cacheFile, fn) {
|
|
49
49
|
const p = createServer({
|
package/test/chunking.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// The whole point of
|
|
3
|
+
// The whole point of kikoeru: the upstream is only ever asked for small
|
|
4
4
|
// ranged pieces (<= 4 MiB), never for a plain GET and never for one
|
|
5
5
|
// big range. These tests record every Range header the fake upstream
|
|
6
6
|
// receives for a > 10 MiB payload and check the pieces exactly: size,
|
package/test/config.test.js
CHANGED
|
@@ -51,7 +51,7 @@ test('loadConfig uses the documented defaults', () => {
|
|
|
51
51
|
assert.equal(cfg.token, null);
|
|
52
52
|
assert.equal(
|
|
53
53
|
cfg.tokenFile,
|
|
54
|
-
path.join(os.homedir(), '.config/
|
|
54
|
+
path.join(os.homedir(), '.config/kikoeru/token'),
|
|
55
55
|
);
|
|
56
56
|
assert.equal(
|
|
57
57
|
cfg.batchCacheFile,
|
|
@@ -61,10 +61,10 @@ test('loadConfig uses the documented defaults', () => {
|
|
|
61
61
|
|
|
62
62
|
test('loadConfig honours every environment override', () => {
|
|
63
63
|
const cfg = loadConfig({
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
KIKOERU_HOST: '0.0.0.0',
|
|
65
|
+
KIKOERU_PORT: '9999',
|
|
66
|
+
KIKOERU_TOKEN: 'from-env',
|
|
67
|
+
KIKOERU_TOKEN_FILE: '/tmp/somewhere/token',
|
|
68
68
|
YT_AUDIO_CACHE: '/tmp/somewhere/cache.json',
|
|
69
69
|
});
|
|
70
70
|
assert.equal(cfg.host, '0.0.0.0');
|
package/test/helpers.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// Shared helpers for the
|
|
3
|
+
// Shared helpers for the kikoeru test suites. This file is not a test
|
|
4
4
|
// itself (it does not match the *.test.js glob); it only builds fake
|
|
5
5
|
// upstreams, temp dirs and small HTTP clients so every suite can stay
|
|
6
6
|
// off the real network.
|
|
@@ -81,7 +81,7 @@ function makeData(size) {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
function tmpDir() {
|
|
84
|
-
return fs.mkdtempSync(path.join(os.tmpdir(), '
|
|
84
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'kikoeru-test-'));
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
function writeCache(file, obj) {
|
package/test/upstream.test.js
CHANGED
|
@@ -34,7 +34,7 @@ let upstream;
|
|
|
34
34
|
let upPort;
|
|
35
35
|
let tmp;
|
|
36
36
|
let hits; // per-path request counts
|
|
37
|
-
let cacheFile; // the batch cache the running
|
|
37
|
+
let cacheFile; // the batch cache the running kikoeru reads
|
|
38
38
|
let onFlakyChunk; // hook fired when /flaky gets a data (non-probe) request
|
|
39
39
|
|
|
40
40
|
function serveRange(req, res) {
|
|
@@ -121,7 +121,7 @@ after(async () => {
|
|
|
121
121
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
-
// Start an
|
|
124
|
+
// Start an kikoeru whose cache points VIDEO_ID at `upstreamPath`.
|
|
125
125
|
async function withServerAt(upstreamPath, fn) {
|
|
126
126
|
writeCache(cacheFile, {
|
|
127
127
|
[VIDEO_ID]: {
|
|
@@ -188,14 +188,14 @@ test('403 on the first data chunk retries with the refreshed cache URL', async (
|
|
|
188
188
|
|
|
189
189
|
test('403 on a data chunk with no cache refresh gives a prompt 502', async () => {
|
|
190
190
|
// The retry re-reads the same dead URL and fails again. Nothing
|
|
191
|
-
// has been sent to the client at that point, so
|
|
191
|
+
// has been sent to the client at that point, so kikoeru must answer
|
|
192
192
|
// promptly with 502 instead of leaving the request hanging.
|
|
193
193
|
hits = {};
|
|
194
194
|
onFlakyChunk = () => {};
|
|
195
195
|
try {
|
|
196
196
|
await withServerAt('/flaky', async (port) => {
|
|
197
197
|
const r = await getExpectTimeout(port, `/${TOKEN}/${VIDEO_ID}`);
|
|
198
|
-
assert.equal(r.timedOut, false, '
|
|
198
|
+
assert.equal(r.timedOut, false, 'kikoeru must answer, not hang');
|
|
199
199
|
assert.equal(r.status, 502);
|
|
200
200
|
});
|
|
201
201
|
} finally {
|
|
@@ -215,7 +215,7 @@ test('500 on a data chunk gives a prompt 502', async () => {
|
|
|
215
215
|
// is sent to the client, so it is answered with a prompt 502.
|
|
216
216
|
await withServerAt('/chunk500', async (port) => {
|
|
217
217
|
const r = await getExpectTimeout(port, `/${TOKEN}/${VIDEO_ID}`);
|
|
218
|
-
assert.equal(r.timedOut, false, '
|
|
218
|
+
assert.equal(r.timedOut, false, 'kikoeru must answer, not hang');
|
|
219
219
|
assert.equal(r.status, 502);
|
|
220
220
|
});
|
|
221
221
|
});
|
|
@@ -259,7 +259,7 @@ test('500 on a later chunk cuts the response short instead of hanging', async ()
|
|
|
259
259
|
// The payload is one chunk plus a little more, so the first chunk
|
|
260
260
|
// is streamed to the client and the failure only happens in
|
|
261
261
|
// streamRest, after the 200 status and the first bytes are out.
|
|
262
|
-
// No error status can be sent then;
|
|
262
|
+
// No error status can be sent then; kikoeru must cut the response so
|
|
263
263
|
// the client sees it end (incomplete) right away, not hang.
|
|
264
264
|
const bigSize = CHUNK_SIZE + 1024;
|
|
265
265
|
const big = makeData(bigSize);
|
|
@@ -298,7 +298,7 @@ test('500 on a later chunk cuts the response short instead of hanging', async ()
|
|
|
298
298
|
const port = await listen(p);
|
|
299
299
|
try {
|
|
300
300
|
const r = await getObserveEnd(port, `/${TOKEN}/${VIDEO_ID}`);
|
|
301
|
-
assert.equal(r.timedOut, false, '
|
|
301
|
+
assert.equal(r.timedOut, false, 'kikoeru must end the response, not hang');
|
|
302
302
|
assert.equal(r.status, 200);
|
|
303
303
|
assert.equal(r.bytes, CHUNK_SIZE, 'only the first chunk was delivered');
|
|
304
304
|
assert.equal(r.complete, false, 'the body ends incomplete');
|