getraw 0.1.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.
- package/.gitattributes +4 -0
- package/CLAUDE.md +57 -0
- package/README.md +166 -0
- package/RESEARCH.md +109 -0
- package/STATUS.md +23 -0
- package/bun.lock +50 -0
- package/bunfig.toml +3 -0
- package/docs/plugin-guide.md +166 -0
- package/docs/supported-sites.md +41 -0
- package/package.json +30 -0
- package/src/cli/index.ts +52 -0
- package/src/cli/options.ts +97 -0
- package/src/core/format-sorter.ts +208 -0
- package/src/core/logger.ts +101 -0
- package/src/core/orchestrator.ts +140 -0
- package/src/core/output-template.ts +58 -0
- package/src/core/types.ts +237 -0
- package/src/downloaders/base.ts +25 -0
- package/src/downloaders/dash.ts +287 -0
- package/src/downloaders/fragment.ts +226 -0
- package/src/downloaders/hls.ts +170 -0
- package/src/downloaders/http.ts +260 -0
- package/src/extractors/archive-org.ts +126 -0
- package/src/extractors/bandcamp.ts +130 -0
- package/src/extractors/base.ts +29 -0
- package/src/extractors/bilibili/bangumi.ts +205 -0
- package/src/extractors/bilibili/index.ts +233 -0
- package/src/extractors/bilibili/wbi.ts +60 -0
- package/src/extractors/coub.ts +137 -0
- package/src/extractors/dailymotion.ts +99 -0
- package/src/extractors/dropbox.ts +52 -0
- package/src/extractors/generic.ts +118 -0
- package/src/extractors/google-drive.ts +106 -0
- package/src/extractors/imgur.ts +156 -0
- package/src/extractors/instagram/index.ts +263 -0
- package/src/extractors/instagram/reels.ts +166 -0
- package/src/extractors/kick/clips.ts +91 -0
- package/src/extractors/kick/index.ts +118 -0
- package/src/extractors/kick/live.ts +89 -0
- package/src/extractors/niconico/index.ts +209 -0
- package/src/extractors/odysee.ts +126 -0
- package/src/extractors/peertube.ts +143 -0
- package/src/extractors/reddit/gallery.ts +124 -0
- package/src/extractors/reddit/index.ts +203 -0
- package/src/extractors/rumble.ts +127 -0
- package/src/extractors/soundcloud/index.ts +161 -0
- package/src/extractors/soundcloud/playlist.ts +129 -0
- package/src/extractors/spotify.ts +97 -0
- package/src/extractors/streamable.ts +121 -0
- package/src/extractors/ted.ts +151 -0
- package/src/extractors/tiktok/index.ts +207 -0
- package/src/extractors/tiktok/user.ts +176 -0
- package/src/extractors/twitch/clips.ts +125 -0
- package/src/extractors/twitch/index.ts +136 -0
- package/src/extractors/twitch/live.ts +132 -0
- package/src/extractors/twitter/index.ts +140 -0
- package/src/extractors/twitter/spaces.ts +200 -0
- package/src/extractors/vimeo/index.ts +187 -0
- package/src/extractors/youtube/captions.ts +111 -0
- package/src/extractors/youtube/index.ts +252 -0
- package/src/extractors/youtube/innertube.ts +364 -0
- package/src/extractors/youtube/nsig.ts +105 -0
- package/src/extractors/youtube/playlist.ts +227 -0
- package/src/extractors/youtube/signature.ts +163 -0
- package/src/networking/client.ts +311 -0
- package/src/networking/cookies.ts +138 -0
- package/src/networking/proxy.ts +132 -0
- package/src/networking/tls.ts +67 -0
- package/src/networking/user-agents.ts +88 -0
- package/src/postprocessors/base.ts +44 -0
- package/src/postprocessors/extract-audio.ts +98 -0
- package/src/postprocessors/ffmpeg.ts +146 -0
- package/src/postprocessors/merge.ts +102 -0
- package/src/postprocessors/metadata.ts +73 -0
- package/src/postprocessors/sponsorblock.ts +162 -0
- package/src/postprocessors/subtitles.ts +285 -0
- package/src/postprocessors/thumbnails.ts +194 -0
- package/src/utils/sanitize.ts +36 -0
- package/src/utils/traverse.ts +68 -0
- package/tests/core/format-sorter.test.ts +96 -0
- package/tests/core/output-template.test.ts +56 -0
- package/tests/core/types.test.ts +79 -0
- package/tests/unit/downloaders/dash.test.ts +57 -0
- package/tests/unit/downloaders/hls.test.ts +120 -0
- package/tests/unit/downloaders/http.test.ts +114 -0
- package/tests/unit/extractors/bilibili.test.ts +83 -0
- package/tests/unit/extractors/instagram.test.ts +273 -0
- package/tests/unit/extractors/kick.test.ts +85 -0
- package/tests/unit/extractors/misc.test.ts +942 -0
- package/tests/unit/extractors/niconico.test.ts +61 -0
- package/tests/unit/extractors/reddit.test.ts +222 -0
- package/tests/unit/extractors/soundcloud.test.ts +299 -0
- package/tests/unit/extractors/tiktok.test.ts +260 -0
- package/tests/unit/extractors/twitch.test.ts +250 -0
- package/tests/unit/extractors/twitter.test.ts +181 -0
- package/tests/unit/extractors/vimeo.test.ts +253 -0
- package/tests/unit/extractors/youtube.test.ts +259 -0
- package/tests/unit/networking/client.test.ts +272 -0
- package/tests/unit/networking/cookies.test.ts +256 -0
- package/tests/unit/networking/proxy.test.ts +137 -0
- package/tests/unit/postprocessors/extract-audio.test.ts +63 -0
- package/tests/unit/postprocessors/merge.test.ts +61 -0
- package/tests/unit/postprocessors/subtitles.test.ts +89 -0
- package/tools/dashboard.ts +112 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { HttpClient } from "../../../src/networking/client";
|
|
3
|
+
import { CookieJar } from "../../../src/networking/cookies";
|
|
4
|
+
import { USER_AGENTS } from "../../../src/networking/user-agents";
|
|
5
|
+
|
|
6
|
+
function mockFetchResponse(
|
|
7
|
+
body: string,
|
|
8
|
+
status = 200,
|
|
9
|
+
headers: Record<string, string> = {},
|
|
10
|
+
): Response {
|
|
11
|
+
return new Response(body, {
|
|
12
|
+
status,
|
|
13
|
+
headers: { "content-type": "text/plain", ...headers },
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("HttpClient - User-Agent rotation", () => {
|
|
18
|
+
test("uses configured user agent by default", async () => {
|
|
19
|
+
const capturedHeaders: Record<string, string>[] = [];
|
|
20
|
+
|
|
21
|
+
const originalFetch = globalThis.fetch;
|
|
22
|
+
globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
|
|
23
|
+
const hdrs = init?.headers as Record<string, string> | undefined;
|
|
24
|
+
if (hdrs) capturedHeaders.push(hdrs);
|
|
25
|
+
return mockFetchResponse("ok");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const client = new HttpClient({ userAgent: "TestAgent/1.0", timeout: 5000 });
|
|
30
|
+
await client.get("http://example.com/");
|
|
31
|
+
expect(capturedHeaders[0]?.["User-Agent"]).toBe("TestAgent/1.0");
|
|
32
|
+
} finally {
|
|
33
|
+
globalThis.fetch = originalFetch;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("rotates user agents when rotateUserAgent is true", async () => {
|
|
38
|
+
const capturedUAs: string[] = [];
|
|
39
|
+
|
|
40
|
+
const originalFetch = globalThis.fetch;
|
|
41
|
+
globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
|
|
42
|
+
const hdrs = init?.headers as Record<string, string> | undefined;
|
|
43
|
+
if (hdrs?.["User-Agent"]) capturedUAs.push(hdrs["User-Agent"]);
|
|
44
|
+
return mockFetchResponse("ok");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const client = new HttpClient({ rotateUserAgent: true, timeout: 5000 });
|
|
49
|
+
for (let i = 0; i < 5; i++) {
|
|
50
|
+
await client.get(`http://example.com/${i}`);
|
|
51
|
+
}
|
|
52
|
+
const known = USER_AGENTS.map((e) => e.ua);
|
|
53
|
+
expect(capturedUAs.every((ua) => known.includes(ua))).toBe(true);
|
|
54
|
+
} finally {
|
|
55
|
+
globalThis.fetch = originalFetch;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("HttpClient - Retry logic", () => {
|
|
61
|
+
test("retries on 503 status", async () => {
|
|
62
|
+
let callCount = 0;
|
|
63
|
+
const originalFetch = globalThis.fetch;
|
|
64
|
+
globalThis.fetch = mock(async () => {
|
|
65
|
+
callCount++;
|
|
66
|
+
if (callCount < 3) return mockFetchResponse("error", 503);
|
|
67
|
+
return mockFetchResponse("ok", 200);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const client = new HttpClient({ maxRetries: 3, retryDelay: 1, timeout: 5000 });
|
|
72
|
+
const res = await client.get("http://example.com/");
|
|
73
|
+
expect(res.ok).toBe(true);
|
|
74
|
+
expect(callCount).toBe(3);
|
|
75
|
+
} finally {
|
|
76
|
+
globalThis.fetch = originalFetch;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("retries on 429 status", async () => {
|
|
81
|
+
let callCount = 0;
|
|
82
|
+
const originalFetch = globalThis.fetch;
|
|
83
|
+
globalThis.fetch = mock(async () => {
|
|
84
|
+
callCount++;
|
|
85
|
+
if (callCount < 2) return mockFetchResponse("rate limited", 429);
|
|
86
|
+
return mockFetchResponse("ok", 200);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const client = new HttpClient({ maxRetries: 3, retryDelay: 1, timeout: 5000 });
|
|
91
|
+
const res = await client.get("http://example.com/");
|
|
92
|
+
expect(res.ok).toBe(true);
|
|
93
|
+
expect(callCount).toBe(2);
|
|
94
|
+
} finally {
|
|
95
|
+
globalThis.fetch = originalFetch;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("returns last error response after exhausting retries", async () => {
|
|
100
|
+
let callCount = 0;
|
|
101
|
+
const originalFetch = globalThis.fetch;
|
|
102
|
+
globalThis.fetch = mock(async () => {
|
|
103
|
+
callCount++;
|
|
104
|
+
return mockFetchResponse("error", 503);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const client = new HttpClient({ maxRetries: 2, retryDelay: 1, timeout: 5000 });
|
|
109
|
+
const res = await client.get("http://example.com/");
|
|
110
|
+
expect(res.status).toBe(503);
|
|
111
|
+
expect(res.ok).toBe(false);
|
|
112
|
+
expect(callCount).toBe(3);
|
|
113
|
+
} finally {
|
|
114
|
+
globalThis.fetch = originalFetch;
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("does not retry on 404", async () => {
|
|
119
|
+
let callCount = 0;
|
|
120
|
+
const originalFetch = globalThis.fetch;
|
|
121
|
+
globalThis.fetch = mock(async () => {
|
|
122
|
+
callCount++;
|
|
123
|
+
return mockFetchResponse("not found", 404);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const client = new HttpClient({ maxRetries: 3, retryDelay: 1, timeout: 5000 });
|
|
128
|
+
const res = await client.get("http://example.com/");
|
|
129
|
+
expect(res.status).toBe(404);
|
|
130
|
+
expect(callCount).toBe(1);
|
|
131
|
+
} finally {
|
|
132
|
+
globalThis.fetch = originalFetch;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("HttpClient - Rate limiting", () => {
|
|
138
|
+
test("enforces delay between requests", async () => {
|
|
139
|
+
const timestamps: number[] = [];
|
|
140
|
+
const originalFetch = globalThis.fetch;
|
|
141
|
+
globalThis.fetch = mock(async () => {
|
|
142
|
+
timestamps.push(Date.now());
|
|
143
|
+
return mockFetchResponse("ok");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const client = new HttpClient({ requestsPerSecond: 5, timeout: 5000 });
|
|
148
|
+
await client.get("http://example.com/1");
|
|
149
|
+
await client.get("http://example.com/2");
|
|
150
|
+
await client.get("http://example.com/3");
|
|
151
|
+
|
|
152
|
+
const minInterval = 1000 / 5;
|
|
153
|
+
for (let i = 1; i < timestamps.length; i++) {
|
|
154
|
+
const gap = timestamps[i] - timestamps[i - 1];
|
|
155
|
+
expect(gap).toBeGreaterThanOrEqual(minInterval - 5);
|
|
156
|
+
}
|
|
157
|
+
} finally {
|
|
158
|
+
globalThis.fetch = originalFetch;
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("no delay when rate limiting is disabled", async () => {
|
|
163
|
+
const timestamps: number[] = [];
|
|
164
|
+
const originalFetch = globalThis.fetch;
|
|
165
|
+
globalThis.fetch = mock(async () => {
|
|
166
|
+
timestamps.push(Date.now());
|
|
167
|
+
return mockFetchResponse("ok");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const client = new HttpClient({ requestsPerSecond: 0, timeout: 5000 });
|
|
172
|
+
await client.get("http://example.com/1");
|
|
173
|
+
await client.get("http://example.com/2");
|
|
174
|
+
const gap = timestamps[1] - timestamps[0];
|
|
175
|
+
expect(gap).toBeLessThan(100);
|
|
176
|
+
} finally {
|
|
177
|
+
globalThis.fetch = originalFetch;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("HttpClient - Cookie jar", () => {
|
|
183
|
+
test("sends Cookie header when jar has matching cookies", async () => {
|
|
184
|
+
const capturedHeaders: Record<string, string>[] = [];
|
|
185
|
+
const originalFetch = globalThis.fetch;
|
|
186
|
+
globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
|
|
187
|
+
const hdrs = init?.headers as Record<string, string> | undefined;
|
|
188
|
+
if (hdrs) capturedHeaders.push(hdrs);
|
|
189
|
+
return mockFetchResponse("ok");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const jar = new CookieJar();
|
|
194
|
+
jar.set({
|
|
195
|
+
domain: "example.com",
|
|
196
|
+
includeSubdomains: false,
|
|
197
|
+
path: "/",
|
|
198
|
+
secure: false,
|
|
199
|
+
expires: Math.floor(Date.now() / 1000) + 86400,
|
|
200
|
+
name: "auth",
|
|
201
|
+
value: "token123",
|
|
202
|
+
});
|
|
203
|
+
const client = new HttpClient({ cookieJar: jar, timeout: 5000 });
|
|
204
|
+
await client.get("http://example.com/");
|
|
205
|
+
expect(capturedHeaders[0]?.["Cookie"]).toContain("auth=token123");
|
|
206
|
+
} finally {
|
|
207
|
+
globalThis.fetch = originalFetch;
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("HttpClient - Caching", () => {
|
|
213
|
+
test("caches GET responses when cacheTtl > 0", async () => {
|
|
214
|
+
let callCount = 0;
|
|
215
|
+
const originalFetch = globalThis.fetch;
|
|
216
|
+
globalThis.fetch = mock(async () => {
|
|
217
|
+
callCount++;
|
|
218
|
+
return mockFetchResponse("cached body");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const client = new HttpClient({ cacheTtl: 60, timeout: 5000 });
|
|
223
|
+
const r1 = await client.get("http://example.com/cached");
|
|
224
|
+
const r2 = await client.get("http://example.com/cached");
|
|
225
|
+
expect(callCount).toBe(1);
|
|
226
|
+
expect(r1.body).toBe(r2.body);
|
|
227
|
+
} finally {
|
|
228
|
+
globalThis.fetch = originalFetch;
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("bypasses cache with noCache option", async () => {
|
|
233
|
+
let callCount = 0;
|
|
234
|
+
const originalFetch = globalThis.fetch;
|
|
235
|
+
globalThis.fetch = mock(async () => {
|
|
236
|
+
callCount++;
|
|
237
|
+
return mockFetchResponse("body");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const client = new HttpClient({ cacheTtl: 60, timeout: 5000 });
|
|
242
|
+
await client.get("http://example.com/nocache", { noCache: true });
|
|
243
|
+
await client.get("http://example.com/nocache", { noCache: true });
|
|
244
|
+
expect(callCount).toBe(2);
|
|
245
|
+
} finally {
|
|
246
|
+
globalThis.fetch = originalFetch;
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("HttpClient - Referer", () => {
|
|
252
|
+
test("sets Referer header from options", async () => {
|
|
253
|
+
const capturedHeaders: Record<string, string>[] = [];
|
|
254
|
+
const originalFetch = globalThis.fetch;
|
|
255
|
+
globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
|
|
256
|
+
const hdrs = init?.headers as Record<string, string> | undefined;
|
|
257
|
+
if (hdrs) capturedHeaders.push(hdrs);
|
|
258
|
+
return mockFetchResponse("ok");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const client = new HttpClient({
|
|
263
|
+
referer: "https://referrer.example.com/",
|
|
264
|
+
timeout: 5000,
|
|
265
|
+
});
|
|
266
|
+
await client.get("http://example.com/");
|
|
267
|
+
expect(capturedHeaders[0]?.["Referer"]).toBe("https://referrer.example.com/");
|
|
268
|
+
} finally {
|
|
269
|
+
globalThis.fetch = originalFetch;
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { describe, expect, test, beforeEach } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
parseNetscapeCookieFile,
|
|
4
|
+
serializeNetscapeCookieFile,
|
|
5
|
+
CookieJar,
|
|
6
|
+
type Cookie,
|
|
7
|
+
} from "../../../src/networking/cookies";
|
|
8
|
+
|
|
9
|
+
const NETSCAPE_FILE = `# Netscape HTTP Cookie File
|
|
10
|
+
# This is a comment
|
|
11
|
+
.example.com TRUE / FALSE 1893456000 session abc123
|
|
12
|
+
.youtube.com TRUE / TRUE 1893456000 VISITOR_INFO1_LIVE xyz789
|
|
13
|
+
.example.com TRUE /path FALSE 0 tracker val1
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
describe("parseNetscapeCookieFile", () => {
|
|
17
|
+
test("parses valid Netscape cookie file", () => {
|
|
18
|
+
const cookies = parseNetscapeCookieFile(NETSCAPE_FILE);
|
|
19
|
+
expect(cookies).toHaveLength(3);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("parses domain correctly", () => {
|
|
23
|
+
const cookies = parseNetscapeCookieFile(NETSCAPE_FILE);
|
|
24
|
+
expect(cookies[0].domain).toBe(".example.com");
|
|
25
|
+
expect(cookies[1].domain).toBe(".youtube.com");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("parses includeSubdomains flag", () => {
|
|
29
|
+
const cookies = parseNetscapeCookieFile(NETSCAPE_FILE);
|
|
30
|
+
expect(cookies[0].includeSubdomains).toBe(true);
|
|
31
|
+
expect(cookies[1].includeSubdomains).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("parses secure flag", () => {
|
|
35
|
+
const cookies = parseNetscapeCookieFile(NETSCAPE_FILE);
|
|
36
|
+
expect(cookies[0].secure).toBe(false);
|
|
37
|
+
expect(cookies[1].secure).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("parses expiry", () => {
|
|
41
|
+
const cookies = parseNetscapeCookieFile(NETSCAPE_FILE);
|
|
42
|
+
expect(cookies[0].expires).toBe(1893456000);
|
|
43
|
+
expect(cookies[2].expires).toBe(0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("parses name and value", () => {
|
|
47
|
+
const cookies = parseNetscapeCookieFile(NETSCAPE_FILE);
|
|
48
|
+
expect(cookies[0].name).toBe("session");
|
|
49
|
+
expect(cookies[0].value).toBe("abc123");
|
|
50
|
+
expect(cookies[1].name).toBe("VISITOR_INFO1_LIVE");
|
|
51
|
+
expect(cookies[1].value).toBe("xyz789");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("skips comment lines", () => {
|
|
55
|
+
const cookies = parseNetscapeCookieFile(NETSCAPE_FILE);
|
|
56
|
+
expect(cookies.every((c) => !c.name.startsWith("#"))).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("skips empty lines", () => {
|
|
60
|
+
const cookies = parseNetscapeCookieFile("\n\n\n");
|
|
61
|
+
expect(cookies).toHaveLength(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("skips malformed lines", () => {
|
|
65
|
+
const bad = "not\tenough\tfields\n";
|
|
66
|
+
const cookies = parseNetscapeCookieFile(bad);
|
|
67
|
+
expect(cookies).toHaveLength(0);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("serializeNetscapeCookieFile", () => {
|
|
72
|
+
test("round-trips cookies", () => {
|
|
73
|
+
const original = parseNetscapeCookieFile(NETSCAPE_FILE);
|
|
74
|
+
const serialized = serializeNetscapeCookieFile(original);
|
|
75
|
+
const reparsed = parseNetscapeCookieFile(serialized);
|
|
76
|
+
expect(reparsed).toHaveLength(original.length);
|
|
77
|
+
for (let i = 0; i < original.length; i++) {
|
|
78
|
+
expect(reparsed[i].name).toBe(original[i].name);
|
|
79
|
+
expect(reparsed[i].value).toBe(original[i].value);
|
|
80
|
+
expect(reparsed[i].domain).toBe(original[i].domain);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("includes header comment", () => {
|
|
85
|
+
const serialized = serializeNetscapeCookieFile([]);
|
|
86
|
+
expect(serialized).toContain("# Netscape HTTP Cookie File");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("CookieJar", () => {
|
|
91
|
+
let jar: CookieJar;
|
|
92
|
+
const futureExpiry = Math.floor(Date.now() / 1000) + 86400;
|
|
93
|
+
const pastExpiry = Math.floor(Date.now() / 1000) - 1;
|
|
94
|
+
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
jar = new CookieJar();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("load parses netscape file", () => {
|
|
100
|
+
jar.load(NETSCAPE_FILE);
|
|
101
|
+
expect(jar.size()).toBe(3);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("set and get cookie", () => {
|
|
105
|
+
const cookie: Cookie = {
|
|
106
|
+
domain: "example.com",
|
|
107
|
+
includeSubdomains: false,
|
|
108
|
+
path: "/",
|
|
109
|
+
secure: false,
|
|
110
|
+
expires: futureExpiry,
|
|
111
|
+
name: "test",
|
|
112
|
+
value: "hello",
|
|
113
|
+
};
|
|
114
|
+
jar.set(cookie);
|
|
115
|
+
const retrieved = jar.get("example.com", "/", "test");
|
|
116
|
+
expect(retrieved).toBeDefined();
|
|
117
|
+
expect(retrieved?.value).toBe("hello");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("get returns undefined for expired cookies", () => {
|
|
121
|
+
const cookie: Cookie = {
|
|
122
|
+
domain: "example.com",
|
|
123
|
+
includeSubdomains: false,
|
|
124
|
+
path: "/",
|
|
125
|
+
secure: false,
|
|
126
|
+
expires: pastExpiry,
|
|
127
|
+
name: "expired",
|
|
128
|
+
value: "yes",
|
|
129
|
+
};
|
|
130
|
+
jar.set(cookie);
|
|
131
|
+
const retrieved = jar.get("example.com", "/", "expired");
|
|
132
|
+
expect(retrieved).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("set overwrites existing cookie with same key", () => {
|
|
136
|
+
const cookie: Cookie = {
|
|
137
|
+
domain: "example.com",
|
|
138
|
+
includeSubdomains: false,
|
|
139
|
+
path: "/",
|
|
140
|
+
secure: false,
|
|
141
|
+
expires: futureExpiry,
|
|
142
|
+
name: "mykey",
|
|
143
|
+
value: "v1",
|
|
144
|
+
};
|
|
145
|
+
jar.set(cookie);
|
|
146
|
+
jar.set({ ...cookie, value: "v2" });
|
|
147
|
+
expect(jar.size()).toBe(1);
|
|
148
|
+
expect(jar.get("example.com", "/", "mykey")?.value).toBe("v2");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("getForUrl matches by host", () => {
|
|
152
|
+
jar.load(NETSCAPE_FILE);
|
|
153
|
+
const cookies = jar.getForUrl("https://www.example.com/");
|
|
154
|
+
expect(cookies.some((c) => c.name === "session")).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("getForUrl excludes secure cookies on http", () => {
|
|
158
|
+
jar.set({
|
|
159
|
+
domain: "example.com",
|
|
160
|
+
includeSubdomains: false,
|
|
161
|
+
path: "/",
|
|
162
|
+
secure: true,
|
|
163
|
+
expires: futureExpiry,
|
|
164
|
+
name: "secureOnly",
|
|
165
|
+
value: "s",
|
|
166
|
+
});
|
|
167
|
+
const httpCookies = jar.getForUrl("http://example.com/");
|
|
168
|
+
expect(httpCookies.some((c) => c.name === "secureOnly")).toBe(false);
|
|
169
|
+
const httpsCookies = jar.getForUrl("https://example.com/");
|
|
170
|
+
expect(httpsCookies.some((c) => c.name === "secureOnly")).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("getForUrl filters by path", () => {
|
|
174
|
+
jar.set({
|
|
175
|
+
domain: "example.com",
|
|
176
|
+
includeSubdomains: false,
|
|
177
|
+
path: "/api",
|
|
178
|
+
secure: false,
|
|
179
|
+
expires: futureExpiry,
|
|
180
|
+
name: "apiOnly",
|
|
181
|
+
value: "a",
|
|
182
|
+
});
|
|
183
|
+
const root = jar.getForUrl("http://example.com/");
|
|
184
|
+
expect(root.some((c) => c.name === "apiOnly")).toBe(false);
|
|
185
|
+
const api = jar.getForUrl("http://example.com/api/v1");
|
|
186
|
+
expect(api.some((c) => c.name === "apiOnly")).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("getCookieHeader returns key=value pairs", () => {
|
|
190
|
+
jar.set({
|
|
191
|
+
domain: "example.com",
|
|
192
|
+
includeSubdomains: false,
|
|
193
|
+
path: "/",
|
|
194
|
+
secure: false,
|
|
195
|
+
expires: futureExpiry,
|
|
196
|
+
name: "a",
|
|
197
|
+
value: "1",
|
|
198
|
+
});
|
|
199
|
+
jar.set({
|
|
200
|
+
domain: "example.com",
|
|
201
|
+
includeSubdomains: false,
|
|
202
|
+
path: "/",
|
|
203
|
+
secure: false,
|
|
204
|
+
expires: futureExpiry,
|
|
205
|
+
name: "b",
|
|
206
|
+
value: "2",
|
|
207
|
+
});
|
|
208
|
+
const header = jar.getCookieHeader("http://example.com/");
|
|
209
|
+
expect(header).toContain("a=1");
|
|
210
|
+
expect(header).toContain("b=2");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("removeExpired clears expired cookies", () => {
|
|
214
|
+
jar.set({
|
|
215
|
+
domain: "example.com",
|
|
216
|
+
includeSubdomains: false,
|
|
217
|
+
path: "/",
|
|
218
|
+
secure: false,
|
|
219
|
+
expires: pastExpiry,
|
|
220
|
+
name: "dead",
|
|
221
|
+
value: "x",
|
|
222
|
+
});
|
|
223
|
+
jar.set({
|
|
224
|
+
domain: "example.com",
|
|
225
|
+
includeSubdomains: false,
|
|
226
|
+
path: "/",
|
|
227
|
+
secure: false,
|
|
228
|
+
expires: futureExpiry,
|
|
229
|
+
name: "alive",
|
|
230
|
+
value: "y",
|
|
231
|
+
});
|
|
232
|
+
expect(jar.size()).toBe(2);
|
|
233
|
+
jar.removeExpired();
|
|
234
|
+
expect(jar.size()).toBe(1);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("clear empties the jar", () => {
|
|
238
|
+
jar.load(NETSCAPE_FILE);
|
|
239
|
+
jar.clear();
|
|
240
|
+
expect(jar.size()).toBe(0);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("session cookies (expires=0) never expire", () => {
|
|
244
|
+
jar.set({
|
|
245
|
+
domain: "example.com",
|
|
246
|
+
includeSubdomains: false,
|
|
247
|
+
path: "/",
|
|
248
|
+
secure: false,
|
|
249
|
+
expires: 0,
|
|
250
|
+
name: "session",
|
|
251
|
+
value: "s",
|
|
252
|
+
});
|
|
253
|
+
const retrieved = jar.get("example.com", "/", "session");
|
|
254
|
+
expect(retrieved).toBeDefined();
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
parseProxyUrl,
|
|
4
|
+
buildProxyAuthHeader,
|
|
5
|
+
createProxyAgent,
|
|
6
|
+
buildConnectRequest,
|
|
7
|
+
ProxyParseError,
|
|
8
|
+
} from "../../../src/networking/proxy";
|
|
9
|
+
|
|
10
|
+
describe("parseProxyUrl", () => {
|
|
11
|
+
test("parses http proxy", () => {
|
|
12
|
+
const proxy = parseProxyUrl("http://proxy.example.com:8080");
|
|
13
|
+
expect(proxy.protocol).toBe("http");
|
|
14
|
+
expect(proxy.host).toBe("proxy.example.com");
|
|
15
|
+
expect(proxy.port).toBe(8080);
|
|
16
|
+
expect(proxy.username).toBeUndefined();
|
|
17
|
+
expect(proxy.password).toBeUndefined();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("parses https proxy", () => {
|
|
21
|
+
const proxy = parseProxyUrl("https://proxy.example.com:443");
|
|
22
|
+
expect(proxy.protocol).toBe("https");
|
|
23
|
+
expect(proxy.port).toBe(443);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("parses socks5 proxy", () => {
|
|
27
|
+
const proxy = parseProxyUrl("socks5://127.0.0.1:1080");
|
|
28
|
+
expect(proxy.protocol).toBe("socks5");
|
|
29
|
+
expect(proxy.host).toBe("127.0.0.1");
|
|
30
|
+
expect(proxy.port).toBe(1080);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("parses proxy with username and password", () => {
|
|
34
|
+
const proxy = parseProxyUrl("http://user:pass@proxy.example.com:8080");
|
|
35
|
+
expect(proxy.username).toBe("user");
|
|
36
|
+
expect(proxy.password).toBe("pass");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("parses proxy with url-encoded credentials", () => {
|
|
40
|
+
const proxy = parseProxyUrl("http://my%40user:p%40ss@proxy.example.com:8080");
|
|
41
|
+
expect(proxy.username).toBe("my@user");
|
|
42
|
+
expect(proxy.password).toBe("p@ss");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("defaults port for socks5 when omitted", () => {
|
|
46
|
+
const proxy = parseProxyUrl("socks5://127.0.0.1");
|
|
47
|
+
expect(proxy.port).toBe(1080);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("throws ProxyParseError for invalid URL", () => {
|
|
51
|
+
expect(() => parseProxyUrl("not a url")).toThrow(ProxyParseError);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("throws ProxyParseError for unsupported protocol", () => {
|
|
55
|
+
expect(() => parseProxyUrl("ftp://proxy.example.com:21")).toThrow(ProxyParseError);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("buildProxyAuthHeader", () => {
|
|
60
|
+
test("returns Basic auth header when credentials present", () => {
|
|
61
|
+
const proxy = parseProxyUrl("http://user:pass@proxy.example.com:8080");
|
|
62
|
+
const header = buildProxyAuthHeader(proxy);
|
|
63
|
+
expect(header).toBe(`Basic ${btoa("user:pass")}`);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("returns undefined when no credentials", () => {
|
|
67
|
+
const proxy = parseProxyUrl("http://proxy.example.com:8080");
|
|
68
|
+
const header = buildProxyAuthHeader(proxy);
|
|
69
|
+
expect(header).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("handles password-less username", () => {
|
|
73
|
+
const proxy = parseProxyUrl("http://user@proxy.example.com:8080");
|
|
74
|
+
const header = buildProxyAuthHeader(proxy);
|
|
75
|
+
expect(header).toBe(`Basic ${btoa("user:")}`);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("createProxyAgent", () => {
|
|
80
|
+
test("returns agent with parsed proxy", () => {
|
|
81
|
+
const agent = createProxyAgent("http://proxy.example.com:8080");
|
|
82
|
+
expect(agent.proxy.host).toBe("proxy.example.com");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("getProxyUrl reconstructs proxy URL without credentials", () => {
|
|
86
|
+
const agent = createProxyAgent("http://proxy.example.com:8080");
|
|
87
|
+
expect(agent.getProxyUrl()).toBe("http://proxy.example.com:8080");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("getProxyUrl includes credentials when present", () => {
|
|
91
|
+
const agent = createProxyAgent("http://user:pass@proxy.example.com:8080");
|
|
92
|
+
expect(agent.getProxyUrl()).toContain("user");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("getAuthHeader returns header when credentials present", () => {
|
|
96
|
+
const agent = createProxyAgent("http://user:pass@proxy.example.com:8080");
|
|
97
|
+
expect(agent.getAuthHeader()).toBeDefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("getAuthHeader returns undefined when no credentials", () => {
|
|
101
|
+
const agent = createProxyAgent("http://proxy.example.com:8080");
|
|
102
|
+
expect(agent.getAuthHeader()).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("buildConnectRequest", () => {
|
|
107
|
+
test("builds valid CONNECT request", () => {
|
|
108
|
+
const proxy = parseProxyUrl("http://proxy.example.com:8080");
|
|
109
|
+
const req = buildConnectRequest({
|
|
110
|
+
targetHost: "api.example.com",
|
|
111
|
+
targetPort: 443,
|
|
112
|
+
proxy,
|
|
113
|
+
});
|
|
114
|
+
expect(req).toContain("CONNECT api.example.com:443 HTTP/1.1");
|
|
115
|
+
expect(req).toContain("Host: api.example.com:443");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("includes Proxy-Authorization when credentials present", () => {
|
|
119
|
+
const proxy = parseProxyUrl("http://user:pass@proxy.example.com:8080");
|
|
120
|
+
const req = buildConnectRequest({
|
|
121
|
+
targetHost: "api.example.com",
|
|
122
|
+
targetPort: 443,
|
|
123
|
+
proxy,
|
|
124
|
+
});
|
|
125
|
+
expect(req).toContain("Proxy-Authorization: Basic");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("omits Proxy-Authorization when no credentials", () => {
|
|
129
|
+
const proxy = parseProxyUrl("http://proxy.example.com:8080");
|
|
130
|
+
const req = buildConnectRequest({
|
|
131
|
+
targetHost: "api.example.com",
|
|
132
|
+
targetPort: 443,
|
|
133
|
+
proxy,
|
|
134
|
+
});
|
|
135
|
+
expect(req).not.toContain("Proxy-Authorization");
|
|
136
|
+
});
|
|
137
|
+
});
|