@karpeleslab/teamclaude 1.0.6 → 1.0.8
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/LICENSE +21 -0
- package/README.md +176 -16
- package/package.json +7 -2
- package/src/account-manager.js +382 -24
- package/src/account-uuid-rewrite.js +115 -0
- package/src/alias.js +123 -0
- package/src/config.js +26 -0
- package/src/h2/frames.js +83 -0
- package/src/h2/hpack.js +314 -0
- package/src/h2/relay.js +417 -0
- package/src/identity.js +65 -0
- package/src/index.js +521 -91
- package/src/json-format-stream.js +65 -0
- package/src/mitm.js +387 -0
- package/src/oauth.js +77 -1
- package/src/prober.js +82 -0
- package/src/request-log.js +194 -0
- package/src/server.js +166 -88
- package/src/sx.js +218 -0
- package/src/tui.js +231 -17
- package/src/upstream-fetch.js +85 -0
- package/src/x509.js +166 -0
package/src/alias.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// `claude` shell alias — print or install/uninstall.
|
|
2
|
+
//
|
|
3
|
+
// The alias simply routes plain `claude` through `teamclaude run`, which itself
|
|
4
|
+
// probes the proxy and falls back to launching claude directly when it's down.
|
|
5
|
+
// So the alias stays a dumb passthrough and all the smarts live in `run`.
|
|
6
|
+
//
|
|
7
|
+
// This only affects interactive shells (aliases aren't seen by editors/scripts
|
|
8
|
+
// that exec `claude` themselves). It's intentionally lighter than a PATH shim:
|
|
9
|
+
// no binary shadowing, one line per rc, trivially reversible.
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, realpathSync } from 'node:fs';
|
|
12
|
+
import { join, dirname } from 'node:path';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
|
|
15
|
+
const MARKER = '# teamclaude alias';
|
|
16
|
+
|
|
17
|
+
/** Basename of the user's login shell, e.g. "zsh". Defaults to bash. */
|
|
18
|
+
export function detectShell() {
|
|
19
|
+
return (process.env.SHELL || '').split('/').pop() || 'bash';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Whether a bare command resolves on the current $PATH. */
|
|
23
|
+
function commandOnPath(cmd) {
|
|
24
|
+
for (const dir of (process.env.PATH || '').split(':')) {
|
|
25
|
+
if (dir && existsSync(join(dir, cmd))) return true;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* How the alias should invoke teamclaude. Prefer the bare `teamclaude` when it's
|
|
32
|
+
* on $PATH; otherwise embed the absolute path to this CLI (quoted) so the alias
|
|
33
|
+
* still works when teamclaude isn't installed on PATH — e.g. run from a clone.
|
|
34
|
+
*/
|
|
35
|
+
export function teamclaudeRef() {
|
|
36
|
+
if (commandOnPath('teamclaude')) return 'teamclaude';
|
|
37
|
+
const entry = process.argv[1];
|
|
38
|
+
if (!entry) return 'teamclaude';
|
|
39
|
+
let abs;
|
|
40
|
+
try { abs = realpathSync(entry); } catch { abs = entry; }
|
|
41
|
+
return `"${abs}"`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The alias definition for a given shell family. */
|
|
45
|
+
export function aliasLine(shell = detectShell(), ref = teamclaudeRef()) {
|
|
46
|
+
const body = `${ref} run --`;
|
|
47
|
+
if (shell === 'fish') return `alias claude '${body}'`;
|
|
48
|
+
return `alias claude='${body}'`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The rc file an alias for this shell should live in. */
|
|
52
|
+
export function rcPathForShell(shell = detectShell()) {
|
|
53
|
+
const home = homedir();
|
|
54
|
+
switch (shell) {
|
|
55
|
+
case 'zsh': return join(home, '.zshrc');
|
|
56
|
+
case 'sh': return join(home, '.profile');
|
|
57
|
+
case 'fish': {
|
|
58
|
+
const cfg = process.env.XDG_CONFIG_HOME || join(home, '.config');
|
|
59
|
+
return join(cfg, 'fish', 'conf.d', 'teamclaude.fish');
|
|
60
|
+
}
|
|
61
|
+
case 'bash':
|
|
62
|
+
default: return join(home, '.bashrc');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function printAlias({ shell = detectShell() } = {}) {
|
|
67
|
+
const line = aliasLine(shell);
|
|
68
|
+
console.log('# Route plain `claude` through the proxy (when it is running; direct otherwise).');
|
|
69
|
+
console.log('# Add this to your shell config:');
|
|
70
|
+
console.log('');
|
|
71
|
+
console.log(` ${line}`);
|
|
72
|
+
console.log('');
|
|
73
|
+
console.log(`# Or install it automatically: teamclaude alias --install`);
|
|
74
|
+
console.log(`# → writes to ${rcPathForShell(shell)} (override with --shell <bash|zsh|fish|sh>)`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function installAlias({ shell = detectShell(), rcPath = rcPathForShell(shell) } = {}) {
|
|
78
|
+
const line = aliasLine(shell);
|
|
79
|
+
mkdirSync(dirname(rcPath), { recursive: true });
|
|
80
|
+
let text = existsSync(rcPath) ? readFileSync(rcPath, 'utf8') : '';
|
|
81
|
+
|
|
82
|
+
if (text.includes(line)) {
|
|
83
|
+
console.log(`Alias already present in ${rcPath}`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (text && !text.endsWith('\n')) text += '\n';
|
|
87
|
+
text += `${MARKER}\n${line}\n`;
|
|
88
|
+
writeFileSync(rcPath, text);
|
|
89
|
+
console.log(`Installed alias in ${rcPath}`);
|
|
90
|
+
console.log('Reload your shell (or open a new terminal) to use it.');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function uninstallAlias({ shell = detectShell(), rcPath = rcPathForShell(shell) } = {}) {
|
|
94
|
+
if (!existsSync(rcPath)) {
|
|
95
|
+
console.log(`Nothing to remove (${rcPath} does not exist)`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const text = readFileSync(rcPath, 'utf8');
|
|
99
|
+
// Strip our marked block: the marker comment + the single line after it.
|
|
100
|
+
// Matching by marker (not by exact alias text) makes this robust even if the
|
|
101
|
+
// embedded teamclaude path differs from what's computed now.
|
|
102
|
+
const blockRe = new RegExp(`\\n?${escapeRe(MARKER)}\\n[^\\n]*\\n?`, 'g');
|
|
103
|
+
let cleaned = text.replace(blockRe, '\n');
|
|
104
|
+
cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
|
|
105
|
+
|
|
106
|
+
if (cleaned === text) {
|
|
107
|
+
console.log(`Alias not found in ${rcPath}`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// For the dedicated fish drop-file, remove it entirely if now empty.
|
|
112
|
+
if (rcPath.endsWith('teamclaude.fish') && cleaned.trim() === '') {
|
|
113
|
+
rmSync(rcPath);
|
|
114
|
+
console.log(`Removed ${rcPath}`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
writeFileSync(rcPath, cleaned);
|
|
118
|
+
console.log(`Removed alias from ${rcPath}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function escapeRe(s) {
|
|
122
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
123
|
+
}
|
package/src/config.js
CHANGED
|
@@ -9,6 +9,32 @@ export function getConfigPath() {
|
|
|
9
9
|
return join(configDir, 'teamclaude.json');
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Path to the runtime state file (a sibling of the config). This holds volatile
|
|
14
|
+
* data learned at runtime — e.g. quota utilization observed passively from
|
|
15
|
+
* traffic — kept out of the hand-editable config so config stays clean and
|
|
16
|
+
* isn't rewritten on every state save.
|
|
17
|
+
*/
|
|
18
|
+
export function getStatePath() {
|
|
19
|
+
const cfg = getConfigPath();
|
|
20
|
+
return cfg.endsWith('.json') ? cfg.replace(/\.json$/, '.state.json') : cfg + '.state';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function loadState() {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(await readFile(getStatePath(), 'utf-8'));
|
|
26
|
+
} catch (err) {
|
|
27
|
+
if (err.code === 'ENOENT') return null;
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function saveState(state) {
|
|
33
|
+
const path = getStatePath();
|
|
34
|
+
await mkdir(dirname(path), { recursive: true });
|
|
35
|
+
await writeFile(path, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
|
|
36
|
+
}
|
|
37
|
+
|
|
12
38
|
export function createDefaultConfig() {
|
|
13
39
|
return {
|
|
14
40
|
proxy: {
|
package/src/h2/frames.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Minimal HTTP/2 framing (RFC 7540 §4) — just what the MITM relay needs:
|
|
2
|
+
// split a byte stream into frames, re-serialize frames, and (de)construct
|
|
3
|
+
// HEADERS/CONTINUATION header blocks so we can rewrite one header and re-emit.
|
|
4
|
+
// All other frame types are forwarded verbatim by the relay.
|
|
5
|
+
|
|
6
|
+
export const FRAME = {
|
|
7
|
+
DATA: 0x0, HEADERS: 0x1, PRIORITY: 0x2, RST_STREAM: 0x3, SETTINGS: 0x4,
|
|
8
|
+
PUSH_PROMISE: 0x5, PING: 0x6, GOAWAY: 0x7, WINDOW_UPDATE: 0x8, CONTINUATION: 0x9,
|
|
9
|
+
};
|
|
10
|
+
export const FLAG = { END_STREAM: 0x1, END_HEADERS: 0x4, PADDED: 0x8, PRIORITY: 0x20 };
|
|
11
|
+
|
|
12
|
+
// Client connection preface: "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" (RFC 7540 §3.5).
|
|
13
|
+
export const PREFACE = Buffer.from('505249202a20485454502f322e300d0a0d0a534d0d0a0d0a', 'hex');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Split as many complete frames as possible out of `buf`.
|
|
17
|
+
* Returns { frames, rest } where rest is the unconsumed tail (incomplete frame).
|
|
18
|
+
* Each frame: { type, flags, streamId, payload, raw } (payload/raw are subarrays).
|
|
19
|
+
*/
|
|
20
|
+
export function readFrames(buf) {
|
|
21
|
+
const frames = [];
|
|
22
|
+
let off = 0;
|
|
23
|
+
while (buf.length - off >= 9) {
|
|
24
|
+
const length = buf.readUIntBE(off, 3);
|
|
25
|
+
if (buf.length - off < 9 + length) break;
|
|
26
|
+
frames.push({
|
|
27
|
+
type: buf[off + 3],
|
|
28
|
+
flags: buf[off + 4],
|
|
29
|
+
streamId: buf.readUInt32BE(off + 5) & 0x7fffffff,
|
|
30
|
+
payload: buf.subarray(off + 9, off + 9 + length),
|
|
31
|
+
raw: buf.subarray(off, off + 9 + length),
|
|
32
|
+
});
|
|
33
|
+
off += 9 + length;
|
|
34
|
+
}
|
|
35
|
+
return { frames, rest: buf.subarray(off) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Serialize one frame. */
|
|
39
|
+
export function buildFrame({ type, flags = 0, streamId, payload = Buffer.alloc(0) }) {
|
|
40
|
+
const h = Buffer.alloc(9);
|
|
41
|
+
h.writeUIntBE(payload.length, 0, 3);
|
|
42
|
+
h[3] = type;
|
|
43
|
+
h[4] = flags;
|
|
44
|
+
h.writeUInt32BE(streamId & 0x7fffffff, 5);
|
|
45
|
+
return Buffer.concat([h, payload]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Pull the header-block fragment out of a HEADERS payload, stripping any
|
|
50
|
+
* PADDED / PRIORITY prefixes. Returns { block, priority } where priority is the
|
|
51
|
+
* 5-byte priority field (or null). Padding is discarded.
|
|
52
|
+
*/
|
|
53
|
+
export function stripHeadersPayload(payload, flags) {
|
|
54
|
+
let off = 0;
|
|
55
|
+
let padLen = 0;
|
|
56
|
+
if (flags & FLAG.PADDED) { padLen = payload[0]; off = 1; }
|
|
57
|
+
let priority = null;
|
|
58
|
+
if (flags & FLAG.PRIORITY) { priority = Buffer.from(payload.subarray(off, off + 5)); off += 5; }
|
|
59
|
+
const block = Buffer.from(payload.subarray(off, payload.length - padLen));
|
|
60
|
+
return { block, priority };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build a HEADERS frame (+ CONTINUATION frames if the block exceeds
|
|
65
|
+
* maxFrameSize) for a re-encoded header block. Padding is not re-added.
|
|
66
|
+
*/
|
|
67
|
+
export function buildHeaderBlock(streamId, block, { endStream = false, priority = null, maxFrameSize = 16384 } = {}) {
|
|
68
|
+
const prio = priority || Buffer.alloc(0);
|
|
69
|
+
const firstCap = Math.max(0, maxFrameSize - prio.length);
|
|
70
|
+
const firstChunk = block.subarray(0, firstCap);
|
|
71
|
+
let rest = block.subarray(firstChunk.length);
|
|
72
|
+
|
|
73
|
+
let hFlags = (endStream ? FLAG.END_STREAM : 0) | (priority ? FLAG.PRIORITY : 0);
|
|
74
|
+
if (rest.length === 0) hFlags |= FLAG.END_HEADERS;
|
|
75
|
+
|
|
76
|
+
const out = [buildFrame({ type: FRAME.HEADERS, flags: hFlags, streamId, payload: Buffer.concat([prio, firstChunk]) })];
|
|
77
|
+
while (rest.length) {
|
|
78
|
+
const chunk = rest.subarray(0, maxFrameSize);
|
|
79
|
+
rest = rest.subarray(chunk.length);
|
|
80
|
+
out.push(buildFrame({ type: FRAME.CONTINUATION, flags: rest.length === 0 ? FLAG.END_HEADERS : 0, streamId, payload: chunk }));
|
|
81
|
+
}
|
|
82
|
+
return Buffer.concat(out);
|
|
83
|
+
}
|
package/src/h2/hpack.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// HPACK header compression (RFC 7541) — pure JS, no deps.
|
|
2
|
+
//
|
|
3
|
+
// Ported clean-room from the compcol Rust implementation (itself transcribed
|
|
4
|
+
// from RFC 7541's appendices). Used by the MITM proxy to decode/re-encode the
|
|
5
|
+
// HTTP/2 header block so it can rewrite only the `authorization` field while
|
|
6
|
+
// leaving everything else intact. Names/values are Buffers (byte-exact).
|
|
7
|
+
//
|
|
8
|
+
// State note: an HPACK codec is stateful across header blocks (the dynamic
|
|
9
|
+
// table evolves), so one HpackDecoder/HpackEncoder instance is kept per
|
|
10
|
+
// direction per connection.
|
|
11
|
+
|
|
12
|
+
// ── Huffman code table (RFC 7541 Appendix B): [code, bitLength] by symbol ──
|
|
13
|
+
// Index = symbol value; index 256 = EOS.
|
|
14
|
+
const CODES = [
|
|
15
|
+
[0x1ff8,13],[0x7fffd8,23],[0xfffffe2,28],[0xfffffe3,28],[0xfffffe4,28],[0xfffffe5,28],[0xfffffe6,28],[0xfffffe7,28],
|
|
16
|
+
[0xfffffe8,28],[0xffffea,24],[0x3ffffffc,30],[0xfffffe9,28],[0xfffffea,28],[0x3ffffffd,30],[0xfffffeb,28],[0xfffffec,28],
|
|
17
|
+
[0xfffffed,28],[0xfffffee,28],[0xfffffef,28],[0xffffff0,28],[0xffffff1,28],[0xffffff2,28],[0x3ffffffe,30],[0xffffff3,28],
|
|
18
|
+
[0xffffff4,28],[0xffffff5,28],[0xffffff6,28],[0xffffff7,28],[0xffffff8,28],[0xffffff9,28],[0xffffffa,28],[0xffffffb,28],
|
|
19
|
+
[0x14,6],[0x3f8,10],[0x3f9,10],[0xffa,12],[0x1ff9,13],[0x15,6],[0xf8,8],[0x7fa,11],
|
|
20
|
+
[0x3fa,10],[0x3fb,10],[0xf9,8],[0x7fb,11],[0xfa,8],[0x16,6],[0x17,6],[0x18,6],
|
|
21
|
+
[0x0,5],[0x1,5],[0x2,5],[0x19,6],[0x1a,6],[0x1b,6],[0x1c,6],[0x1d,6],
|
|
22
|
+
[0x1e,6],[0x1f,6],[0x5c,7],[0xfb,8],[0x7ffc,15],[0x20,6],[0xffb,12],[0x3fc,10],
|
|
23
|
+
[0x1ffa,13],[0x21,6],[0x5d,7],[0x5e,7],[0x5f,7],[0x60,7],[0x61,7],[0x62,7],
|
|
24
|
+
[0x63,7],[0x64,7],[0x65,7],[0x66,7],[0x67,7],[0x68,7],[0x69,7],[0x6a,7],
|
|
25
|
+
[0x6b,7],[0x6c,7],[0x6d,7],[0x6e,7],[0x6f,7],[0x70,7],[0x71,7],[0x72,7],
|
|
26
|
+
[0xfc,8],[0x73,7],[0xfd,8],[0x1ffb,13],[0x7fff0,19],[0x1ffc,13],[0x3ffc,14],[0x22,6],
|
|
27
|
+
[0x7ffd,15],[0x3,5],[0x23,6],[0x4,5],[0x24,6],[0x5,5],[0x25,6],[0x26,6],
|
|
28
|
+
[0x27,6],[0x6,5],[0x74,7],[0x75,7],[0x28,6],[0x29,6],[0x2a,6],[0x7,5],
|
|
29
|
+
[0x2b,6],[0x76,7],[0x2c,6],[0x8,5],[0x9,5],[0x2d,6],[0x77,7],[0x78,7],
|
|
30
|
+
[0x79,7],[0x7a,7],[0x7b,7],[0x7ffe,15],[0x7fc,11],[0x3ffd,14],[0x1ffd,13],[0xffffffc,28],
|
|
31
|
+
[0xfffe6,20],[0x3fffd2,22],[0xfffe7,20],[0xfffe8,20],[0x3fffd3,22],[0x3fffd4,22],[0x3fffd5,22],[0x7fffd9,23],
|
|
32
|
+
[0x3fffd6,22],[0x7fffda,23],[0x7fffdb,23],[0x7fffdc,23],[0x7fffdd,23],[0x7fffde,23],[0xffffeb,24],[0x7fffdf,23],
|
|
33
|
+
[0xffffec,24],[0xffffed,24],[0x3fffd7,22],[0x7fffe0,23],[0xffffee,24],[0x7fffe1,23],[0x7fffe2,23],[0x7fffe3,23],
|
|
34
|
+
[0x7fffe4,23],[0x1fffdc,21],[0x3fffd8,22],[0x7fffe5,23],[0x3fffd9,22],[0x7fffe6,23],[0x7fffe7,23],[0xffffef,24],
|
|
35
|
+
[0x3fffda,22],[0x1fffdd,21],[0xfffe9,20],[0x3fffdb,22],[0x3fffdc,22],[0x7fffe8,23],[0x7fffe9,23],[0x1fffde,21],
|
|
36
|
+
[0x7fffea,23],[0x3fffdd,22],[0x3fffde,22],[0xfffff0,24],[0x1fffdf,21],[0x3fffdf,22],[0x7fffeb,23],[0x7fffec,23],
|
|
37
|
+
[0x1fffe0,21],[0x1fffe1,21],[0x3fffe0,22],[0x1fffe2,21],[0x7fffed,23],[0x3fffe1,22],[0x7fffee,23],[0x7fffef,23],
|
|
38
|
+
[0xfffea,20],[0x3fffe2,22],[0x3fffe3,22],[0x3fffe4,22],[0x7ffff0,23],[0x3fffe5,22],[0x3fffe6,22],[0x7ffff1,23],
|
|
39
|
+
[0x3ffffe0,26],[0x3ffffe1,26],[0xfffeb,20],[0x7fff1,19],[0x3fffe7,22],[0x7ffff2,23],[0x3fffe8,22],[0x1ffffec,25],
|
|
40
|
+
[0x3ffffe2,26],[0x3ffffe3,26],[0x3ffffe4,26],[0x7ffffde,27],[0x7ffffdf,27],[0x3ffffe5,26],[0xfffff1,24],[0x1ffffed,25],
|
|
41
|
+
[0x7fff2,19],[0x1fffe3,21],[0x3ffffe6,26],[0x7ffffe0,27],[0x7ffffe1,27],[0x3ffffe7,26],[0x7ffffe2,27],[0xfffff2,24],
|
|
42
|
+
[0x1fffe4,21],[0x1fffe5,21],[0x3ffffe8,26],[0x3ffffe9,26],[0xffffffd,28],[0x7ffffe3,27],[0x7ffffe4,27],[0x7ffffe5,27],
|
|
43
|
+
[0xfffec,20],[0xfffff3,24],[0xfffed,20],[0x1fffe6,21],[0x3fffe9,22],[0x1fffe7,21],[0x1fffe8,21],[0x7ffff3,23],
|
|
44
|
+
[0x3fffea,22],[0x3fffeb,22],[0x1ffffee,25],[0x1ffffef,25],[0xfffff4,24],[0xfffff5,24],[0x3ffffea,26],[0x7ffff4,23],
|
|
45
|
+
[0x3ffffeb,26],[0x7ffffe6,27],[0x3ffffec,26],[0x3ffffed,26],[0x7ffffe7,27],[0x7ffffe8,27],[0x7ffffe9,27],[0x7ffffea,27],
|
|
46
|
+
[0x7ffffeb,27],[0xffffffe,28],[0x7ffffec,27],[0x7ffffed,27],[0x7ffffee,27],[0x7ffffef,27],[0x7fffff0,27],[0x3ffffee,26],
|
|
47
|
+
[0x3fffffff,30],
|
|
48
|
+
];
|
|
49
|
+
const EOS = 256;
|
|
50
|
+
|
|
51
|
+
class HpackError extends Error {}
|
|
52
|
+
|
|
53
|
+
// ── Huffman decode trie (built once) ──────────────────────────────────────
|
|
54
|
+
const trie = { c0: [-1], c1: [-1], leaf: [-1] }; // node 0 = root
|
|
55
|
+
(function buildTrie() {
|
|
56
|
+
for (let sym = 0; sym < CODES.length; sym++) {
|
|
57
|
+
const [code, len] = CODES[sym];
|
|
58
|
+
let node = 0;
|
|
59
|
+
for (let i = len - 1; i >= 0; i--) {
|
|
60
|
+
const bit = (code >>> i) & 1;
|
|
61
|
+
const arr = bit ? trie.c1 : trie.c0;
|
|
62
|
+
let next = arr[node];
|
|
63
|
+
if (next === -1) {
|
|
64
|
+
next = trie.leaf.length;
|
|
65
|
+
trie.c0.push(-1); trie.c1.push(-1); trie.leaf.push(-1);
|
|
66
|
+
arr[node] = next;
|
|
67
|
+
}
|
|
68
|
+
node = next;
|
|
69
|
+
}
|
|
70
|
+
trie.leaf[node] = sym;
|
|
71
|
+
}
|
|
72
|
+
})();
|
|
73
|
+
|
|
74
|
+
export function huffmanEncodedLen(buf) {
|
|
75
|
+
let bits = 0;
|
|
76
|
+
for (const b of buf) bits += CODES[b][1];
|
|
77
|
+
return Math.ceil(bits / 8);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function huffmanEncode(buf) {
|
|
81
|
+
const out = [];
|
|
82
|
+
let acc = 0n, nbits = 0n;
|
|
83
|
+
for (const b of buf) {
|
|
84
|
+
const [code, len] = CODES[b];
|
|
85
|
+
acc = (acc << BigInt(len)) | BigInt(code);
|
|
86
|
+
nbits += BigInt(len);
|
|
87
|
+
while (nbits >= 8n) {
|
|
88
|
+
nbits -= 8n;
|
|
89
|
+
out.push(Number((acc >> nbits) & 0xffn));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (nbits > 0n) {
|
|
93
|
+
const pad = 8n - nbits;
|
|
94
|
+
out.push(Number(((acc << pad) | ((1n << pad) - 1n)) & 0xffn));
|
|
95
|
+
}
|
|
96
|
+
return Buffer.from(out);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function huffmanDecode(buf) {
|
|
100
|
+
const out = [];
|
|
101
|
+
let node = 0, depth = 0, allOnes = true;
|
|
102
|
+
for (const byte of buf) {
|
|
103
|
+
for (let i = 7; i >= 0; i--) {
|
|
104
|
+
const bit = (byte >> i) & 1;
|
|
105
|
+
node = bit ? trie.c1[node] : trie.c0[node];
|
|
106
|
+
depth++; allOnes = allOnes && bit === 1;
|
|
107
|
+
const sym = trie.leaf[node];
|
|
108
|
+
if (sym >= 0) {
|
|
109
|
+
if (sym === EOS) throw new HpackError('EOS symbol in input');
|
|
110
|
+
out.push(sym);
|
|
111
|
+
node = 0; depth = 0; allOnes = true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (depth >= 8 || !allOnes) throw new HpackError('bad Huffman padding');
|
|
116
|
+
return Buffer.from(out);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── integer codec (RFC 7541 §5.1) ─────────────────────────────────────────
|
|
120
|
+
export function encodeInt(out, value, n, flags) {
|
|
121
|
+
const maxPrefix = (1 << n) - 1;
|
|
122
|
+
if (value < maxPrefix) { out.push(flags | value); return; }
|
|
123
|
+
out.push(flags | maxPrefix);
|
|
124
|
+
let v = value - maxPrefix;
|
|
125
|
+
while (v >= 128) { out.push((v & 0x7f) | 0x80); v = Math.floor(v / 128); }
|
|
126
|
+
out.push(v);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function decodeInt(buf, pos, n) {
|
|
130
|
+
const maxPrefix = (1 << n) - 1;
|
|
131
|
+
if (pos >= buf.length) throw new HpackError('truncated integer');
|
|
132
|
+
let value = buf[pos] & maxPrefix;
|
|
133
|
+
let p = pos + 1;
|
|
134
|
+
if (value < maxPrefix) return [value, p];
|
|
135
|
+
let shift = 0;
|
|
136
|
+
for (;;) {
|
|
137
|
+
if (p >= buf.length) throw new HpackError('truncated integer');
|
|
138
|
+
const b = buf[p++];
|
|
139
|
+
if (shift >= 53) throw new HpackError('integer overflow');
|
|
140
|
+
value += (b & 0x7f) * 2 ** shift;
|
|
141
|
+
if (!(b & 0x80)) break;
|
|
142
|
+
shift += 7;
|
|
143
|
+
}
|
|
144
|
+
return [value, p];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── static table (RFC 7541 Appendix A) ─────────────────────────────────────
|
|
148
|
+
const STATIC_TABLE = [
|
|
149
|
+
[':authority', ''], [':method', 'GET'], [':method', 'POST'], [':path', '/'],
|
|
150
|
+
[':path', '/index.html'], [':scheme', 'http'], [':scheme', 'https'], [':status', '200'],
|
|
151
|
+
[':status', '204'], [':status', '206'], [':status', '304'], [':status', '400'],
|
|
152
|
+
[':status', '404'], [':status', '500'], ['accept-charset', ''], ['accept-encoding', 'gzip, deflate'],
|
|
153
|
+
['accept-language', ''], ['accept-ranges', ''], ['accept', ''], ['access-control-allow-origin', ''],
|
|
154
|
+
['age', ''], ['allow', ''], ['authorization', ''], ['cache-control', ''],
|
|
155
|
+
['content-disposition', ''], ['content-encoding', ''], ['content-language', ''], ['content-length', ''],
|
|
156
|
+
['content-location', ''], ['content-range', ''], ['content-type', ''], ['cookie', ''],
|
|
157
|
+
['date', ''], ['etag', ''], ['expect', ''], ['expires', ''],
|
|
158
|
+
['from', ''], ['host', ''], ['if-match', ''], ['if-modified-since', ''],
|
|
159
|
+
['if-none-match', ''], ['if-range', ''], ['if-unmodified-since', ''], ['last-modified', ''],
|
|
160
|
+
['link', ''], ['location', ''], ['max-forwards', ''], ['proxy-authenticate', ''],
|
|
161
|
+
['proxy-authorization', ''], ['range', ''], ['referer', ''], ['refresh', ''],
|
|
162
|
+
['retry-after', ''], ['server', ''], ['set-cookie', ''], ['strict-transport-security', ''],
|
|
163
|
+
['transfer-encoding', ''], ['user-agent', ''], ['vary', ''], ['via', ''],
|
|
164
|
+
['www-authenticate', ''],
|
|
165
|
+
].map(([n, v]) => [Buffer.from(n), Buffer.from(v)]);
|
|
166
|
+
const STATIC_LEN = STATIC_TABLE.length;
|
|
167
|
+
const ENTRY_OVERHEAD = 32;
|
|
168
|
+
|
|
169
|
+
class DynamicTable {
|
|
170
|
+
constructor(maxSize) { this.entries = []; this.size = 0; this.maxSize = maxSize; }
|
|
171
|
+
setMaxSize(m) { this.maxSize = m; this.#evict(0); }
|
|
172
|
+
#entrySize(n, v) { return n.length + v.length + ENTRY_OVERHEAD; }
|
|
173
|
+
#evict(incoming) {
|
|
174
|
+
while (this.size + incoming > this.maxSize && this.entries.length) {
|
|
175
|
+
const [n, v] = this.entries.pop();
|
|
176
|
+
this.size -= this.#entrySize(n, v);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
insert(name, value) {
|
|
180
|
+
const need = this.#entrySize(name, value);
|
|
181
|
+
this.#evict(need);
|
|
182
|
+
if (need <= this.maxSize) { this.entries.unshift([name, value]); this.size += need; }
|
|
183
|
+
}
|
|
184
|
+
get(index) {
|
|
185
|
+
if (index === 0) return null;
|
|
186
|
+
if (index <= STATIC_LEN) return STATIC_TABLE[index - 1];
|
|
187
|
+
const e = this.entries[index - STATIC_LEN - 1];
|
|
188
|
+
return e || null;
|
|
189
|
+
}
|
|
190
|
+
find(name, value) {
|
|
191
|
+
let nameOnly = null;
|
|
192
|
+
for (let i = 0; i < STATIC_TABLE.length; i++) {
|
|
193
|
+
const [n, v] = STATIC_TABLE[i];
|
|
194
|
+
if (n.equals(name)) {
|
|
195
|
+
if (v.equals(value)) return { index: i + 1, valueMatched: true };
|
|
196
|
+
if (nameOnly === null) nameOnly = i + 1;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
for (let pos = 0; pos < this.entries.length; pos++) {
|
|
200
|
+
const [n, v] = this.entries[pos];
|
|
201
|
+
if (n.equals(name)) {
|
|
202
|
+
const index = STATIC_LEN + 1 + pos;
|
|
203
|
+
if (v.equals(value)) return { index, valueMatched: true };
|
|
204
|
+
if (nameOnly === null) nameOnly = index;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return nameOnly === null ? null : { index: nameOnly, valueMatched: false };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export const DEFAULT_TABLE_SIZE = 4096;
|
|
212
|
+
|
|
213
|
+
function readString(block, pos) {
|
|
214
|
+
if (pos >= block.length) throw new HpackError('truncated string');
|
|
215
|
+
const huff = (block[pos] & 0x80) !== 0;
|
|
216
|
+
const [len, p] = decodeInt(block, pos, 7);
|
|
217
|
+
const end = p + len;
|
|
218
|
+
if (end > block.length) throw new HpackError('truncated string');
|
|
219
|
+
const raw = block.subarray(p, end);
|
|
220
|
+
return [huff ? huffmanDecode(raw) : Buffer.from(raw), end];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// A decoded/encodable field. name/value are Buffers; sensitive = never-indexed.
|
|
224
|
+
export class HpackDecoder {
|
|
225
|
+
constructor(maxSize = DEFAULT_TABLE_SIZE) { this.table = new DynamicTable(maxSize); this.sizeLimit = maxSize; }
|
|
226
|
+
decode(block) {
|
|
227
|
+
const fields = [];
|
|
228
|
+
let pos = 0;
|
|
229
|
+
while (pos < block.length) {
|
|
230
|
+
const b = block[pos];
|
|
231
|
+
if (b & 0x80) { // §6.1 indexed
|
|
232
|
+
const [idx, np] = decodeInt(block, pos, 7); pos = np;
|
|
233
|
+
if (idx === 0) throw new HpackError('index 0');
|
|
234
|
+
const e = this.table.get(idx); if (!e) throw new HpackError('bad index');
|
|
235
|
+
fields.push({ name: e[0], value: e[1], sensitive: false });
|
|
236
|
+
} else if (b & 0x40) { // §6.2.1 literal w/ incremental indexing
|
|
237
|
+
const [name, value, np] = this.#readLiteral(block, pos, 6); pos = np;
|
|
238
|
+
this.table.insert(name, value);
|
|
239
|
+
fields.push({ name, value, sensitive: false });
|
|
240
|
+
} else if (b & 0x20) { // §6.3 dynamic table size update
|
|
241
|
+
const [newMax, np] = decodeInt(block, pos, 5); pos = np;
|
|
242
|
+
if (newMax > this.sizeLimit) throw new HpackError('size update over limit');
|
|
243
|
+
this.table.setMaxSize(newMax);
|
|
244
|
+
} else { // §6.2.2 / §6.2.3 (4-bit prefix; 0x10 = never indexed)
|
|
245
|
+
const sensitive = (b & 0x10) !== 0;
|
|
246
|
+
const [name, value, np] = this.#readLiteral(block, pos, 4); pos = np;
|
|
247
|
+
fields.push({ name, value, sensitive });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return fields;
|
|
251
|
+
}
|
|
252
|
+
#readLiteral(block, pos, prefix) {
|
|
253
|
+
const [idx, p0] = decodeInt(block, pos, prefix);
|
|
254
|
+
let p = p0, name;
|
|
255
|
+
if (idx === 0) { const [n, np] = readString(block, p); name = n; p = np; }
|
|
256
|
+
else { const e = this.table.get(idx); if (!e) throw new HpackError('bad name index'); name = e[0]; }
|
|
257
|
+
const [value, np] = readString(block, p);
|
|
258
|
+
return [name, value, np];
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export class HpackEncoder {
|
|
263
|
+
constructor(maxSize = DEFAULT_TABLE_SIZE) {
|
|
264
|
+
this.table = new DynamicTable(maxSize);
|
|
265
|
+
this.useHuffman = true;
|
|
266
|
+
this.pendingSizeUpdate = maxSize === DEFAULT_TABLE_SIZE ? null : maxSize;
|
|
267
|
+
// When false, never insert into / reference the dynamic table — emit every
|
|
268
|
+
// field as a literal (full static matches still use the static index). This
|
|
269
|
+
// makes the encoder independent of the peer's SETTINGS_HEADER_TABLE_SIZE,
|
|
270
|
+
// which the MITM relay relies on (it doesn't track the upstream's table).
|
|
271
|
+
this.dynamicIndexing = true;
|
|
272
|
+
}
|
|
273
|
+
encode(fields) {
|
|
274
|
+
const out = [];
|
|
275
|
+
if (this.pendingSizeUpdate !== null) { encodeInt(out, this.pendingSizeUpdate, 5, 0x20); this.pendingSizeUpdate = null; }
|
|
276
|
+
for (const f of fields) this.#field(out, f);
|
|
277
|
+
return Buffer.from(out);
|
|
278
|
+
}
|
|
279
|
+
#field(out, f) {
|
|
280
|
+
const name = Buffer.isBuffer(f.name) ? f.name : Buffer.from(f.name);
|
|
281
|
+
const value = Buffer.isBuffer(f.value) ? f.value : Buffer.from(f.value);
|
|
282
|
+
if (f.sensitive) {
|
|
283
|
+
const m = this.table.find(name, value);
|
|
284
|
+
const nameIdx = (m && this.table.get(m.index)[0].equals(name)) ? m.index : null;
|
|
285
|
+
this.#literal(out, 0x10, 4, nameIdx, name, value);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const m = this.table.find(name, value);
|
|
289
|
+
if (m && m.valueMatched) { encodeInt(out, m.index, 7, 0x80); return; } // indexed (static when no indexing)
|
|
290
|
+
if (this.dynamicIndexing) {
|
|
291
|
+
this.#literal(out, 0x40, 6, m ? m.index : null, name, value); // literal w/ incremental indexing
|
|
292
|
+
this.table.insert(name, value);
|
|
293
|
+
} else {
|
|
294
|
+
this.#literal(out, 0x00, 4, m ? m.index : null, name, value); // literal without indexing; no insert
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
#literal(out, pattern, prefix, nameIdx, name, value) {
|
|
298
|
+
if (nameIdx !== null) encodeInt(out, nameIdx, prefix, pattern);
|
|
299
|
+
else { encodeInt(out, 0, prefix, pattern); this.#string(out, name); }
|
|
300
|
+
this.#string(out, value);
|
|
301
|
+
}
|
|
302
|
+
#string(out, s) {
|
|
303
|
+
if (this.useHuffman && huffmanEncodedLen(s) < s.length) {
|
|
304
|
+
const coded = huffmanEncode(s);
|
|
305
|
+
encodeInt(out, coded.length, 7, 0x80);
|
|
306
|
+
for (const b of coded) out.push(b);
|
|
307
|
+
} else {
|
|
308
|
+
encodeInt(out, s.length, 7, 0x00);
|
|
309
|
+
for (const b of s) out.push(b);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export { HpackError };
|