@karpeleslab/teamclaude 1.0.7 → 1.0.9
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 +85 -4
- package/package.json +9 -1
- package/src/account-manager.js +182 -27
- package/src/account-uuid-rewrite.js +115 -0
- package/src/h2/frames.js +83 -0
- package/src/h2/hpack.js +314 -0
- package/src/h2/relay.js +417 -0
- package/src/index.js +177 -22
- package/src/json-format-stream.js +65 -0
- package/src/mitm.js +387 -0
- package/src/oauth.js +76 -1
- package/src/prober.js +82 -0
- package/src/request-log.js +194 -0
- package/src/server.js +148 -90
- package/src/sx.js +218 -0
- package/src/tui.js +275 -7
- package/src/upstream-fetch.js +85 -0
- package/src/x509.js +166 -0
package/src/index.js
CHANGED
|
@@ -9,7 +9,10 @@ import { createProxyServer } from './server.js';
|
|
|
9
9
|
import { importCredentials, loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
|
|
10
10
|
import { sameIdentity, orgKey, matchAccounts } from './identity.js';
|
|
11
11
|
import * as alias from './alias.js';
|
|
12
|
+
import { ensureCerts } from './mitm.js';
|
|
13
|
+
import { Prober } from './prober.js';
|
|
12
14
|
import { TUI } from './tui.js';
|
|
15
|
+
import { SxManager } from './sx.js';
|
|
13
16
|
|
|
14
17
|
const args = process.argv.slice(2);
|
|
15
18
|
const command = args[0];
|
|
@@ -65,6 +68,10 @@ switch (command) {
|
|
|
65
68
|
aliasCommand();
|
|
66
69
|
process.exit(0);
|
|
67
70
|
break;
|
|
71
|
+
case 'probe':
|
|
72
|
+
await probeCommand();
|
|
73
|
+
process.exit(0);
|
|
74
|
+
break;
|
|
68
75
|
case 'help':
|
|
69
76
|
case '--help':
|
|
70
77
|
case '-h':
|
|
@@ -117,6 +124,10 @@ async function serverCommand() {
|
|
|
117
124
|
});
|
|
118
125
|
if (savedState?.quota) accountManager.restoreQuotaState(savedState.quota);
|
|
119
126
|
|
|
127
|
+
// With quota restored, pick the best account up front (highest priority /
|
|
128
|
+
// soonest-resetting weekly window) instead of defaulting to the first one.
|
|
129
|
+
accountManager.selectActiveAccount();
|
|
130
|
+
|
|
120
131
|
// Periodically persist quota (and once more on shutdown) to the state file.
|
|
121
132
|
const persistQuotaState = () =>
|
|
122
133
|
saveState({ quota: accountManager.exportQuotaState() })
|
|
@@ -154,14 +165,54 @@ async function serverCommand() {
|
|
|
154
165
|
}).catch(err => console.error(`[TeamClaude] Failed to save refreshed token: ${err.message}`));
|
|
155
166
|
});
|
|
156
167
|
const port = config.proxy.port;
|
|
157
|
-
const
|
|
168
|
+
const headless = args.includes('--headless') || args.includes('--no-tui');
|
|
169
|
+
const useTUI = !headless && process.stdout.isTTY && process.stdin.isTTY;
|
|
170
|
+
|
|
171
|
+
// Opt-in background quota probe (config.quotaProbeSeconds, default 0 = off).
|
|
172
|
+
let prober = null;
|
|
173
|
+
|
|
174
|
+
// sx.org proxy (IP-based-429 workaround). Dormant unless an API key is set in
|
|
175
|
+
// config.sx.apiKey; when set we provision a proxy and route upstream through it.
|
|
176
|
+
const sx = new SxManager({ log: console.error });
|
|
177
|
+
if (config.sx?.apiKey) {
|
|
178
|
+
const r = await sx.configure(config.sx.apiKey, config.sx.mode);
|
|
179
|
+
if (!r.ok) console.error(`[TeamClaude] sx.org disabled: ${r.error}`);
|
|
180
|
+
} else if (config.sx?.mode) {
|
|
181
|
+
await sx.setMode(config.sx.mode);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Re-sync accounts from disk without a restart. The TUI's 'R' key, the
|
|
185
|
+
// POST /teamclaude/reload endpoint, and the CLI notify after add/change all
|
|
186
|
+
// funnel through here. Returns the number of newly added accounts. Also picks
|
|
187
|
+
// up a changed probe interval so `teamclaude probe` applies live.
|
|
188
|
+
const reloadAccounts = async () => {
|
|
189
|
+
const diskConfig = await loadConfig();
|
|
190
|
+
if (!diskConfig) return 0;
|
|
191
|
+
const added = await syncAccountsFromDisk(diskConfig, config, accountManager);
|
|
192
|
+
// Apply an sx.org key/mode change made on disk (e.g. via POST /teamclaude/reload).
|
|
193
|
+
const diskSxKey = diskConfig.sx?.apiKey || null;
|
|
194
|
+
const diskSxMode = diskConfig.sx?.mode || 'always';
|
|
195
|
+
if (diskSxKey !== sx.apiKey || diskSxMode !== sx.mode) {
|
|
196
|
+
config.sx = diskConfig.sx;
|
|
197
|
+
if (diskSxKey) await sx.configure(diskSxKey, diskSxMode);
|
|
198
|
+
else { sx.disable(); await sx.setMode(diskSxMode); }
|
|
199
|
+
}
|
|
200
|
+
if (prober) {
|
|
201
|
+
const ms = (diskConfig.quotaProbeSeconds || 0) * 1000;
|
|
202
|
+
if (ms !== prober.intervalMs) {
|
|
203
|
+
config.quotaProbeSeconds = diskConfig.quotaProbeSeconds || 0;
|
|
204
|
+
prober.reschedule(ms);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return added;
|
|
208
|
+
};
|
|
158
209
|
|
|
159
210
|
let tui = null;
|
|
160
211
|
let hooks = {};
|
|
161
212
|
|
|
162
213
|
if (useTUI) {
|
|
163
214
|
tui = new TUI({
|
|
164
|
-
accountManager, config,
|
|
215
|
+
accountManager, config, sx,
|
|
165
216
|
saveConfig: () => atomicConfigUpdate(async diskConfig => {
|
|
166
217
|
// Write in-memory accounts as the authoritative state, preserving
|
|
167
218
|
// extra disk-only fields (e.g. importFrom) where the account still exists.
|
|
@@ -177,13 +228,15 @@ async function serverCommand() {
|
|
|
177
228
|
const diskAcct = diskConfig.accounts.find(d => sameIdentity(d, a));
|
|
178
229
|
return diskAcct ? { ...diskAcct, ...live } : live;
|
|
179
230
|
});
|
|
231
|
+
// Persist sx.org settings (set/cleared from the TUI settings screen).
|
|
232
|
+
if (config.sx) diskConfig.sx = config.sx; else delete diskConfig.sx;
|
|
233
|
+
// Persist other runtime-tunable settings edited from the TUI.
|
|
234
|
+
if (config.switchThreshold != null) diskConfig.switchThreshold = config.switchThreshold;
|
|
235
|
+
if (config.quotaProbeSeconds != null) diskConfig.quotaProbeSeconds = config.quotaProbeSeconds;
|
|
180
236
|
}),
|
|
181
|
-
syncAccounts:
|
|
182
|
-
const diskConfig = await loadConfig();
|
|
183
|
-
if (!diskConfig) return 0;
|
|
184
|
-
return syncAccountsFromDisk(diskConfig, config, accountManager);
|
|
185
|
-
},
|
|
237
|
+
syncAccounts: reloadAccounts,
|
|
186
238
|
onQuit: async () => {
|
|
239
|
+
prober?.stop();
|
|
187
240
|
if (quotaSaveInterval) clearInterval(quotaSaveInterval);
|
|
188
241
|
await persistQuotaState();
|
|
189
242
|
server.close(() => process.exit(0));
|
|
@@ -196,7 +249,10 @@ async function serverCommand() {
|
|
|
196
249
|
};
|
|
197
250
|
}
|
|
198
251
|
|
|
199
|
-
|
|
252
|
+
// Expose reload to the proxy's control endpoint (works with or without TUI).
|
|
253
|
+
hooks.reload = reloadAccounts;
|
|
254
|
+
|
|
255
|
+
const server = createProxyServer(accountManager, config, hooks, sx);
|
|
200
256
|
// Catch bind-time errors (e.g. EADDRINUSE) only. Once the socket is bound we
|
|
201
257
|
// remove this handler so a later runtime 'error' isn't misreported as a
|
|
202
258
|
// listen failure and exit the whole proxy.
|
|
@@ -237,9 +293,14 @@ async function serverCommand() {
|
|
|
237
293
|
quotaSaveInterval = setInterval(persistQuotaState, 60_000);
|
|
238
294
|
quotaSaveInterval.unref?.();
|
|
239
295
|
|
|
296
|
+
// Start the opt-in quota probe (no-op when quotaProbeSeconds is 0).
|
|
297
|
+
prober = new Prober(accountManager, { intervalMs: (config.quotaProbeSeconds || 0) * 1000 });
|
|
298
|
+
prober.start();
|
|
299
|
+
|
|
240
300
|
if (!tui) {
|
|
241
301
|
const shutdown = async () => {
|
|
242
302
|
console.log('\n[TeamClaude] Shutting down...');
|
|
303
|
+
prober?.stop();
|
|
243
304
|
clearInterval(quotaSaveInterval);
|
|
244
305
|
await persistQuotaState();
|
|
245
306
|
server.close(() => process.exit(0));
|
|
@@ -383,9 +444,13 @@ async function envCommand() {
|
|
|
383
444
|
async function runCommand() {
|
|
384
445
|
const config = await loadOrCreateConfig();
|
|
385
446
|
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
|
|
447
|
+
// Args after 'run'. teamclaude flags (e.g. --mitm) are recognized only before
|
|
448
|
+
// an optional `--` separator; everything after `--` goes verbatim to claude.
|
|
449
|
+
const rest = args.slice(1);
|
|
450
|
+
const sep = rest.indexOf('--');
|
|
451
|
+
const tcFlags = sep >= 0 ? rest.slice(0, sep) : rest;
|
|
452
|
+
const useMitm = tcFlags.includes('--mitm');
|
|
453
|
+
const claudeArgs = sep >= 0 ? rest.slice(sep + 1) : rest.filter(a => a !== '--mitm');
|
|
389
454
|
|
|
390
455
|
// Route through the proxy only when it's actually up; otherwise launch claude
|
|
391
456
|
// directly so a stopped proxy doesn't break `claude`. This is what lets the
|
|
@@ -393,10 +458,23 @@ async function runCommand() {
|
|
|
393
458
|
const port = config.proxy.port;
|
|
394
459
|
const env = { ...process.env };
|
|
395
460
|
if (await isProxyUp(port)) {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
461
|
+
if (useMitm) {
|
|
462
|
+
// Route ALL of claude's traffic through us as an HTTPS forward proxy, so
|
|
463
|
+
// even hardcoded api.anthropic.com endpoints (e.g. the design MCP) get the
|
|
464
|
+
// real token injected. claude trusts our MITM leaf via NODE_EXTRA_CA_CERTS.
|
|
465
|
+
const host = upstreamHost(config);
|
|
466
|
+
const { caPath } = await ensureCerts(host);
|
|
467
|
+
const proxyUrl = `http://127.0.0.1:${port}`;
|
|
468
|
+
env.HTTPS_PROXY = env.HTTP_PROXY = env.https_proxy = env.http_proxy = proxyUrl;
|
|
469
|
+
env.NO_PROXY = env.no_proxy = ''; // ensure nothing (esp. the upstream host) bypasses us
|
|
470
|
+
env.NODE_EXTRA_CA_CERTS = caPath;
|
|
471
|
+
delete env.ANTHROPIC_BASE_URL;
|
|
472
|
+
} else {
|
|
473
|
+
// Only set ANTHROPIC_BASE_URL — Claude Code keeps its own OAuth token
|
|
474
|
+
// which the proxy accepts from localhost. Not setting ANTHROPIC_API_KEY
|
|
475
|
+
// lets Claude Code stay in subscription mode (full model access).
|
|
476
|
+
env.ANTHROPIC_BASE_URL = `http://localhost:${port}`;
|
|
477
|
+
}
|
|
400
478
|
} else {
|
|
401
479
|
console.error(`[TeamClaude] Proxy not running on port ${port} — launching claude directly (start it with: teamclaude server)`);
|
|
402
480
|
}
|
|
@@ -440,10 +518,12 @@ async function statusCommand() {
|
|
|
440
518
|
console.log(` ${acct.name} (${acct.type})${current}`);
|
|
441
519
|
console.log(` Status: ${acct.status}${acct.disabled ? ' (disabled)' : ''}`);
|
|
442
520
|
|
|
443
|
-
if (q.unified5h != null || q.unified7d != null) {
|
|
521
|
+
if (q.unified5h != null || q.unified7d != null || q.unified7dSonnet != null) {
|
|
444
522
|
const ses = q.unified5h != null ? (q.unified5h * 100).toFixed(1) + '%' : '-';
|
|
445
523
|
const wk = q.unified7d != null ? (q.unified7d * 100).toFixed(1) + '%' : '-';
|
|
446
|
-
|
|
524
|
+
let line = ` Session: ${ses} used Weekly: ${wk} used`;
|
|
525
|
+
if (q.unified7dSonnet != null) line += ` Sonnet7d: ${(q.unified7dSonnet * 100).toFixed(1)}% used`;
|
|
526
|
+
console.log(line);
|
|
447
527
|
} else {
|
|
448
528
|
const tok = q.tokensLimit ? ((1 - q.tokensRemaining / q.tokensLimit) * 100).toFixed(1) + '%' : '-';
|
|
449
529
|
const req = q.requestsLimit ? ((1 - q.requestsRemaining / q.requestsLimit) * 100).toFixed(1) + '%' : '-';
|
|
@@ -645,6 +725,42 @@ function aliasCommand() {
|
|
|
645
725
|
}
|
|
646
726
|
}
|
|
647
727
|
|
|
728
|
+
// ── probe ───────────────────────────────────────────────────
|
|
729
|
+
|
|
730
|
+
async function probeCommand() {
|
|
731
|
+
const config = await loadOrCreateConfig();
|
|
732
|
+
const arg = args[1];
|
|
733
|
+
|
|
734
|
+
if (arg === undefined) {
|
|
735
|
+
const cur = config.quotaProbeSeconds || 0;
|
|
736
|
+
console.log(cur > 0 ? `Quota probe: every ${cur}s` : 'Quota probe: off (passive only)');
|
|
737
|
+
console.log('Set with: teamclaude probe <off|seconds> e.g. teamclaude probe 300');
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
let seconds;
|
|
742
|
+
if (arg === 'off' || arg === '0') {
|
|
743
|
+
seconds = 0;
|
|
744
|
+
} else {
|
|
745
|
+
seconds = parseInt(arg, 10);
|
|
746
|
+
if (Number.isNaN(seconds) || seconds < 0) {
|
|
747
|
+
console.error('Usage: teamclaude probe <off|seconds>');
|
|
748
|
+
process.exit(1);
|
|
749
|
+
}
|
|
750
|
+
if (seconds > 0 && seconds < 30) {
|
|
751
|
+
console.error('Minimum probe interval is 30s (to avoid hammering the usage endpoint).');
|
|
752
|
+
process.exit(1);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
config.quotaProbeSeconds = seconds;
|
|
757
|
+
await saveConfig(config);
|
|
758
|
+
console.log(seconds > 0
|
|
759
|
+
? `Quota probe set to every ${seconds}s (reads /api/oauth/usage; does not spend quota).`
|
|
760
|
+
: 'Quota probe disabled (passive only).');
|
|
761
|
+
await notifyRunningServer(config);
|
|
762
|
+
}
|
|
763
|
+
|
|
648
764
|
// ── remove ──────────────────────────────────────────────────
|
|
649
765
|
|
|
650
766
|
/**
|
|
@@ -724,6 +840,7 @@ async function priorityCommand() {
|
|
|
724
840
|
account.priority = priority;
|
|
725
841
|
await saveConfig(config);
|
|
726
842
|
console.log(`Set priority of "${account.name}" to ${priority} (lower = preferred)`);
|
|
843
|
+
await notifyRunningServer(config);
|
|
727
844
|
}
|
|
728
845
|
|
|
729
846
|
// ── enable / disable ────────────────────────────────────────
|
|
@@ -751,9 +868,7 @@ async function setDisabledCommand(disabled) {
|
|
|
751
868
|
}
|
|
752
869
|
await saveConfig(config);
|
|
753
870
|
console.log(`${disabled ? 'Disabled' : 'Enabled'} account "${account.name}"`);
|
|
754
|
-
|
|
755
|
-
console.log('(restart or reload the running server to retry it if it was in an error state)');
|
|
756
|
-
}
|
|
871
|
+
await notifyRunningServer(config);
|
|
757
872
|
}
|
|
758
873
|
|
|
759
874
|
// ── help ────────────────────────────────────────────────────
|
|
@@ -764,12 +879,15 @@ function showHelp() {
|
|
|
764
879
|
Usage: teamclaude [command] [options]
|
|
765
880
|
|
|
766
881
|
Commands:
|
|
767
|
-
server Start the proxy server (default)
|
|
882
|
+
server Start the proxy server (default; --headless to skip the TUI)
|
|
768
883
|
import Import credentials from Claude Code
|
|
769
884
|
login OAuth login via browser
|
|
770
885
|
login --api Add an API key account
|
|
771
886
|
env Print env vars to use with Claude
|
|
772
|
-
run [-- args...]
|
|
887
|
+
run [--mitm] [-- args...]
|
|
888
|
+
Run Claude Code through the proxy (direct if it's down);
|
|
889
|
+
--mitm routes via an HTTPS forward proxy + local CA so even
|
|
890
|
+
hardcoded api.anthropic.com endpoints are intercepted
|
|
773
891
|
alias Print a shell alias so plain 'claude' routes via the proxy
|
|
774
892
|
(--install to write it to your shell rc; --uninstall to remove)
|
|
775
893
|
status Show proxy & account status (live)
|
|
@@ -778,6 +896,8 @@ Commands:
|
|
|
778
896
|
disable <name> Temporarily exclude an account from rotation
|
|
779
897
|
enable <name> Re-enable a disabled account (also clears a stuck error)
|
|
780
898
|
priority <name> <n> Set rotation priority (lower = preferred; --first/--last)
|
|
899
|
+
probe [off|secs] Opt-in background quota refresh for idle accounts
|
|
900
|
+
(off by default; reads usage endpoint, spends no quota)
|
|
781
901
|
api <path> Call an API endpoint with account credentials
|
|
782
902
|
help Show this help
|
|
783
903
|
|
|
@@ -788,6 +908,14 @@ Options:
|
|
|
788
908
|
--json JSON Import from inline JSON (import), e.g.:
|
|
789
909
|
--json '{"accessToken":"...","refreshToken":"...","expiresAt":1234}'
|
|
790
910
|
--log-to DIR Log full requests/responses to DIR (server, one file per request)
|
|
911
|
+
--headless Run the server without the interactive TUI (for backgrounding)
|
|
912
|
+
--mitm (run) route claude via the HTTPS forward proxy + local CA
|
|
913
|
+
|
|
914
|
+
The server always accepts both base-URL and proxy/CONNECT clients, so instances
|
|
915
|
+
launched with and without --mitm can share one server.
|
|
916
|
+
|
|
917
|
+
A running server re-syncs accounts from config on POST /teamclaude/reload
|
|
918
|
+
(local only). add/login/enable/disable/priority trigger it automatically.
|
|
791
919
|
|
|
792
920
|
Config: ${getConfigPath()}
|
|
793
921
|
`);
|
|
@@ -862,6 +990,7 @@ async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
|
|
|
862
990
|
|
|
863
991
|
await saveConfig(config);
|
|
864
992
|
console.log(`Saved to ${getConfigPath()}`);
|
|
993
|
+
await notifyRunningServer(config);
|
|
865
994
|
}
|
|
866
995
|
|
|
867
996
|
// ── config sync helpers ─────────────────────────────────────
|
|
@@ -988,6 +1117,32 @@ function argValue(flag) {
|
|
|
988
1117
|
return (i >= 0 && args[i + 1]) ? args[i + 1] : null;
|
|
989
1118
|
}
|
|
990
1119
|
|
|
1120
|
+
// Hostname of the configured upstream (the host MITM-intercepts under `run --mitm`).
|
|
1121
|
+
function upstreamHost(config) {
|
|
1122
|
+
try { return new URL(config.upstream || 'https://api.anthropic.com').hostname; }
|
|
1123
|
+
catch { return 'api.anthropic.com'; }
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Best-effort: tell a running server (if any) to re-sync accounts from config so
|
|
1127
|
+
// CLI changes take effect without a restart. A closed local port refuses the
|
|
1128
|
+
// connection immediately, so this is a no-op (and near-instant) when nothing is
|
|
1129
|
+
// running. Reload picks up new accounts, credential, priority, and enable/disable
|
|
1130
|
+
// changes; account removals still need a restart.
|
|
1131
|
+
async function notifyRunningServer(config) {
|
|
1132
|
+
const port = config?.proxy?.port;
|
|
1133
|
+
if (!port) return;
|
|
1134
|
+
try {
|
|
1135
|
+
const res = await fetch(`http://localhost:${port}/teamclaude/reload`, {
|
|
1136
|
+
method: 'POST',
|
|
1137
|
+
headers: { 'x-api-key': config.proxy?.apiKey || '' },
|
|
1138
|
+
});
|
|
1139
|
+
if (res.ok) {
|
|
1140
|
+
const data = await res.json().catch(() => ({}));
|
|
1141
|
+
console.log(`Reloaded running server${data.added ? ` (+${data.added} new account)` : ''}.`);
|
|
1142
|
+
}
|
|
1143
|
+
} catch { /* no server running — nothing to notify */ }
|
|
1144
|
+
}
|
|
1145
|
+
|
|
991
1146
|
// Quick liveness probe: is something listening on the local proxy port?
|
|
992
1147
|
// A successful TCP connect is enough (the proxy is local). Times out fast so a
|
|
993
1148
|
// down proxy doesn't add noticeable latency to `claude` launches via the alias.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Streaming JSON pretty-printer (no regex, no buffering of the whole body).
|
|
2
|
+
//
|
|
3
|
+
// Built on the same idea as the account_uuid patcher: walk the bytes once,
|
|
4
|
+
// tracking only enough state (nesting depth, in-string, escape) to know where
|
|
5
|
+
// we are, and re-emit with indentation as we go. This lets the request logger
|
|
6
|
+
// flush a readable body to disk *as it streams* — so when a request blocks
|
|
7
|
+
// mid-flight, the partial (pretty) body is already on disk and you can see how
|
|
8
|
+
// far it got. Bodies can be ~1M tokens, so we never hold more than the current
|
|
9
|
+
// chunk.
|
|
10
|
+
//
|
|
11
|
+
// Whitespace outside strings is dropped and re-inserted; strings (including any
|
|
12
|
+
// whitespace/escapes inside them) are copied verbatim. Operates on latin1 so a
|
|
13
|
+
// multi-byte UTF-8 sequence split across chunks is preserved byte-for-byte.
|
|
14
|
+
export class JsonStreamFormatter {
|
|
15
|
+
constructor(indent = 2) {
|
|
16
|
+
this.pad = ' '.repeat(indent);
|
|
17
|
+
this.depth = 0;
|
|
18
|
+
this.inStr = false;
|
|
19
|
+
this.esc = false;
|
|
20
|
+
this.freshContainer = false; // just opened { or [ — first element needs a newline+indent
|
|
21
|
+
this.started = false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
nl(depth) { return '\n' + this.pad.repeat(depth); }
|
|
25
|
+
|
|
26
|
+
// Feed a chunk; returns the formatted text for that chunk.
|
|
27
|
+
push(buf) {
|
|
28
|
+
const text = Buffer.isBuffer(buf) ? buf.toString('latin1') : String(buf);
|
|
29
|
+
let out = '';
|
|
30
|
+
for (let i = 0; i < text.length; i++) {
|
|
31
|
+
const ch = text[i];
|
|
32
|
+
|
|
33
|
+
if (this.inStr) {
|
|
34
|
+
out += ch;
|
|
35
|
+
if (this.esc) this.esc = false;
|
|
36
|
+
else if (ch === '\\') this.esc = true;
|
|
37
|
+
else if (ch === '"') this.inStr = false;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Outside a string: collapse existing whitespace; we re-insert our own.
|
|
42
|
+
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') continue;
|
|
43
|
+
|
|
44
|
+
if (ch === '}' || ch === ']') {
|
|
45
|
+
this.depth--;
|
|
46
|
+
// Empty container: emit "{}" / "[]" with no inner newline.
|
|
47
|
+
if (this.freshContainer) { this.freshContainer = false; out += ch; }
|
|
48
|
+
else out += this.nl(this.depth) + ch;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Any other token. If it's the first token inside a just-opened
|
|
53
|
+
// container, break the line and indent first.
|
|
54
|
+
if (this.freshContainer) { out += this.nl(this.depth); this.freshContainer = false; }
|
|
55
|
+
|
|
56
|
+
if (ch === '{' || ch === '[') { out += ch; this.depth++; this.freshContainer = true; continue; }
|
|
57
|
+
if (ch === ',') { out += ',' + this.nl(this.depth); continue; }
|
|
58
|
+
if (ch === ':') { out += ': '; continue; }
|
|
59
|
+
if (ch === '"') { this.inStr = true; out += ch; continue; }
|
|
60
|
+
out += ch; // number / true / false / null character
|
|
61
|
+
}
|
|
62
|
+
this.started = this.started || out.length > 0;
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
}
|