@obtoai/agent-bridge 0.1.0-beta.1
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 +190 -0
- package/README.md +120 -0
- package/bin/obto-bridge.js +52 -0
- package/cli/init.js +157 -0
- package/cli/logout.js +23 -0
- package/cli/rotate-token.js +88 -0
- package/cli/start.js +4 -0
- package/cli/status.js +48 -0
- package/cli/whoami.js +70 -0
- package/package.json +37 -0
- package/src/bridge-http.js +87 -0
- package/src/bridge-mcp-server.js +125 -0
- package/src/claude-driver.js +433 -0
- package/src/codex-driver.js +206 -0
- package/src/config.js +65 -0
- package/src/daemon.js +171 -0
- package/src/driver.js +47 -0
- package/src/session-scanner.js +53 -0
- package/src/state.js +61 -0
- package/src/stream-client.js +144 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Loads daemon config from `~/.obto-bridge/config.json`, with env overrides.
|
|
4
|
+
// Env always wins so users can run the daemon non-interactively in CI / launchd
|
|
5
|
+
// without touching the config file.
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
|
|
11
|
+
const CONFIG_DIR = path.join(os.homedir(), '.obto-bridge');
|
|
12
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
|
|
13
|
+
|
|
14
|
+
const loadConfig = () => {
|
|
15
|
+
let file = {};
|
|
16
|
+
try {
|
|
17
|
+
file = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
18
|
+
} catch (_) {
|
|
19
|
+
// File missing/invalid — fall back to env-only.
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const cfg = {
|
|
23
|
+
baseUrl: process.env.BRIDGE_BASE_URL || file.baseUrl || 'https://agent-bridge.obto.co',
|
|
24
|
+
originHost: process.env.BRIDGE_ORIGIN_HOST || file.originHost || 'agent-bridge.obto.co',
|
|
25
|
+
accountId: process.env.BRIDGE_ACCOUNT_ID || file.accountId || '',
|
|
26
|
+
apiToken: process.env.BRIDGE_API_TOKEN || file.apiToken || '',
|
|
27
|
+
agentId: process.env.AGENT_ID || file.agentId || 'unnamed-agent',
|
|
28
|
+
projectDir: path.resolve(
|
|
29
|
+
process.env.BRIDGE_PROJECT_DIR || file.projectDir || process.cwd(),
|
|
30
|
+
),
|
|
31
|
+
// Which coding agent the daemon drives: 'claude' (Claude Agent SDK) or
|
|
32
|
+
// 'codex' (Codex SDK). Claude is the default and the more capable driver
|
|
33
|
+
// (per-tool permission relay); Codex runs unattended in a fixed sandbox.
|
|
34
|
+
agent: String(process.env.BRIDGE_AGENT || file.agent || 'claude')
|
|
35
|
+
.trim()
|
|
36
|
+
.toLowerCase(),
|
|
37
|
+
// Codex-only: filesystem sandbox for unattended Codex sessions, since
|
|
38
|
+
// Codex has no per-tool human relay. read-only | workspace-write |
|
|
39
|
+
// danger-full-access. BRIDGE_ALLOW_ALL=1 forces danger-full-access.
|
|
40
|
+
codexSandbox:
|
|
41
|
+
process.env.BRIDGE_CODEX_SANDBOX || file.codexSandbox || 'workspace-write',
|
|
42
|
+
// Optional / advanced
|
|
43
|
+
relayPermissions: (process.env.BRIDGE_RELAY_PERMISSIONS === '1') || !!file.relayPermissions,
|
|
44
|
+
allowAll: (process.env.BRIDGE_ALLOW_ALL === '1') || !!file.allowAll,
|
|
45
|
+
relayTimeoutMs:
|
|
46
|
+
parseInt(process.env.BRIDGE_RELAY_TIMEOUT_MS, 10) ||
|
|
47
|
+
file.relayTimeoutMs ||
|
|
48
|
+
600000,
|
|
49
|
+
liveGuardDisabled: (process.env.BRIDGE_LIVE_GUARD_DISABLED === '1') || !!file.liveGuardDisabled,
|
|
50
|
+
liveThresholdMs:
|
|
51
|
+
parseInt(process.env.BRIDGE_LIVE_THRESHOLD_MS, 10) ||
|
|
52
|
+
file.liveThresholdMs ||
|
|
53
|
+
60000,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (!cfg.apiToken) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
'No apiToken configured. Set BRIDGE_API_TOKEN env or write to ' + CONFIG_PATH,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return cfg;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
module.exports = { loadConfig, CONFIG_DIR, CONFIG_PATH };
|
package/src/daemon.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { loadConfig } = require('./config');
|
|
4
|
+
const { startStream } = require('./stream-client');
|
|
5
|
+
const { loadState, saveState, getBinding, setBinding } = require('./state');
|
|
6
|
+
const { drive, tryResolvePermission, activeAgent } = require('./driver');
|
|
7
|
+
const { postAgentActivity } = require('./bridge-http');
|
|
8
|
+
|
|
9
|
+
const log = (level, msg, data) => {
|
|
10
|
+
const line = { ts: new Date().toISOString(), level, msg };
|
|
11
|
+
if (data !== undefined) line.data = data;
|
|
12
|
+
console.log(JSON.stringify(line));
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Fire-and-forget activity ping. The "agent is working" indicator is a UX
|
|
16
|
+
// nicety — a failed ping must never interfere with the actual turn, so we
|
|
17
|
+
// swallow every error and never await it in the hot path. We still log both
|
|
18
|
+
// failure modes: a rejected promise (network error) AND a resolved-but-non-ok
|
|
19
|
+
// response (4xx/5xx) — fetch does not throw on HTTP errors, so a 404/401 would
|
|
20
|
+
// otherwise pass silently.
|
|
21
|
+
const emitActivity = (threadId, activityState) => {
|
|
22
|
+
postAgentActivity(threadId, activityState)
|
|
23
|
+
.then((res) => {
|
|
24
|
+
if (!res || !res.ok) {
|
|
25
|
+
log('warn', 'agent-activity ping rejected', {
|
|
26
|
+
threadId,
|
|
27
|
+
state: activityState,
|
|
28
|
+
status: res && res.status,
|
|
29
|
+
body: res && res.data,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
.catch((err) => {
|
|
34
|
+
log('warn', 'agent-activity ping failed', {
|
|
35
|
+
threadId,
|
|
36
|
+
state: activityState,
|
|
37
|
+
error: err && err.message ? err.message : String(err),
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
let cfg;
|
|
43
|
+
try {
|
|
44
|
+
cfg = loadConfig();
|
|
45
|
+
} catch (err) {
|
|
46
|
+
log('error', 'config load failed', { error: err && err.message });
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const state = loadState();
|
|
51
|
+
let stopped = false;
|
|
52
|
+
let stream = null;
|
|
53
|
+
|
|
54
|
+
const handleEvent = async (sseEvent) => {
|
|
55
|
+
if (sseEvent.event !== 'reply') return;
|
|
56
|
+
let payload;
|
|
57
|
+
try {
|
|
58
|
+
payload = JSON.parse(sseEvent.data);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
log('error', 'unparseable sse data', { error: e.message });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const threadId = String(payload.threadId || '').trim();
|
|
65
|
+
if (!threadId) {
|
|
66
|
+
log('warn', 'event missing threadId — dropping', { messageId: payload.messageId });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const binding = getBinding(state, threadId);
|
|
71
|
+
log('event', 'reply received', {
|
|
72
|
+
threadId,
|
|
73
|
+
author: payload.author,
|
|
74
|
+
messageId: payload.messageId,
|
|
75
|
+
hasBinding: !!binding,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Permission-relay replies: resolve the pending request inside the driver and
|
|
79
|
+
// skip starting a new turn.
|
|
80
|
+
if (tryResolvePermission(threadId, payload.body || '', log)) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Tell the browser a turn has started. Cleared in the finally below so the
|
|
85
|
+
// indicator drops whether the turn succeeds, skips, or throws.
|
|
86
|
+
emitActivity(threadId, 'working');
|
|
87
|
+
try {
|
|
88
|
+
const result = await drive({
|
|
89
|
+
threadId,
|
|
90
|
+
projectDir: cfg.projectDir,
|
|
91
|
+
binding,
|
|
92
|
+
payload,
|
|
93
|
+
log,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (
|
|
97
|
+
result &&
|
|
98
|
+
result.sessionId &&
|
|
99
|
+
(!binding || binding.sessionId !== result.sessionId)
|
|
100
|
+
) {
|
|
101
|
+
setBinding(state, threadId, {
|
|
102
|
+
sessionId: result.sessionId,
|
|
103
|
+
projectDir: result.projectDir,
|
|
104
|
+
jsonlPath: result.jsonlPath,
|
|
105
|
+
lastJsonlMtimeMs: result.lastJsonlMtimeMs || null,
|
|
106
|
+
agentId: cfg.agentId,
|
|
107
|
+
createdAt: new Date().toISOString(),
|
|
108
|
+
lastDriveAt: new Date().toISOString(),
|
|
109
|
+
});
|
|
110
|
+
log('info', 'binding created', { threadId, sessionId: result.sessionId });
|
|
111
|
+
} else if (binding && !result.skipped) {
|
|
112
|
+
binding.lastDriveAt = new Date().toISOString();
|
|
113
|
+
if (result && result.lastJsonlMtimeMs) {
|
|
114
|
+
binding.lastJsonlMtimeMs = result.lastJsonlMtimeMs;
|
|
115
|
+
}
|
|
116
|
+
saveState(state);
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
log('error', 'handle failed', {
|
|
120
|
+
threadId,
|
|
121
|
+
error: err && err.message ? err.message : String(err),
|
|
122
|
+
});
|
|
123
|
+
} finally {
|
|
124
|
+
emitActivity(threadId, 'idle');
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const start = () => {
|
|
129
|
+
log('info', 'starting daemon', {
|
|
130
|
+
baseUrl: cfg.baseUrl,
|
|
131
|
+
accountId: cfg.accountId,
|
|
132
|
+
agentId: cfg.agentId,
|
|
133
|
+
agent: activeAgent(),
|
|
134
|
+
projectDir: cfg.projectDir,
|
|
135
|
+
boundThreads: Object.keys(state.bindings || {}),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const url = cfg.baseUrl.replace(/\/$/, '') + '/api/bridge/stream';
|
|
139
|
+
stream = startStream({
|
|
140
|
+
url,
|
|
141
|
+
// Re-read config on every (re)connect so a rotated token (via
|
|
142
|
+
// `obto-bridge rotate-token`, which rewrites config.json) is picked up
|
|
143
|
+
// automatically without restarting the daemon.
|
|
144
|
+
getHeaders: () => {
|
|
145
|
+
const fresh = loadConfig();
|
|
146
|
+
return {
|
|
147
|
+
'OBTO-ORIGIN-HOST': fresh.originHost,
|
|
148
|
+
Authorization: 'Bearer ' + fresh.apiToken,
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
onEvent: (ev) => {
|
|
152
|
+
handleEvent(ev).catch((err) => {
|
|
153
|
+
log('error', 'handleEvent threw', { error: err && err.message });
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
log,
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const shutdown = (signal) => {
|
|
161
|
+
if (stopped) return;
|
|
162
|
+
stopped = true;
|
|
163
|
+
log('info', 'shutting down', { signal });
|
|
164
|
+
try { stream && stream.stop(); } catch (_) {}
|
|
165
|
+
setTimeout(() => process.exit(0), 200);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
169
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
170
|
+
|
|
171
|
+
start();
|
package/src/driver.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Agent-agnostic driver selector. The daemon drives whichever coding agent the
|
|
4
|
+
// operator configured — Claude (via the Claude Agent SDK) or Codex (via the
|
|
5
|
+
// Codex SDK) — chosen by `agent` in config.json / the BRIDGE_AGENT env var.
|
|
6
|
+
// Everything else in the daemon (SSE, state, the bridge HTTP client) is
|
|
7
|
+
// agent-neutral; only the driver differs.
|
|
8
|
+
//
|
|
9
|
+
// The codex driver is require()d lazily, so a Claude-only install never loads
|
|
10
|
+
// @openai/codex-sdk (and vice versa).
|
|
11
|
+
|
|
12
|
+
const { loadConfig } = require('./config');
|
|
13
|
+
|
|
14
|
+
let resolved = null;
|
|
15
|
+
|
|
16
|
+
const pick = () => {
|
|
17
|
+
if (resolved) return resolved;
|
|
18
|
+
let agent = 'claude';
|
|
19
|
+
try {
|
|
20
|
+
agent = (loadConfig().agent || 'claude').toLowerCase();
|
|
21
|
+
} catch (_) {
|
|
22
|
+
// config unreadable — default to claude
|
|
23
|
+
}
|
|
24
|
+
if (agent === 'codex') {
|
|
25
|
+
resolved = { name: 'codex', mod: require('./codex-driver') };
|
|
26
|
+
} else {
|
|
27
|
+
resolved = { name: 'claude', mod: require('./claude-driver') };
|
|
28
|
+
}
|
|
29
|
+
return resolved;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const drive = (params) => pick().mod.drive(params);
|
|
33
|
+
|
|
34
|
+
// Permission relay is Claude-only — Codex exposes no per-tool callback. For a
|
|
35
|
+
// Codex daemon this is always a no-op so the reply path goes straight to
|
|
36
|
+
// drive(); for Claude it delegates to the real relay resolver.
|
|
37
|
+
const tryResolvePermission = (threadId, body, log) => {
|
|
38
|
+
const p = pick();
|
|
39
|
+
if (typeof p.mod.tryResolvePermission === 'function') {
|
|
40
|
+
return p.mod.tryResolvePermission(threadId, body, log);
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const activeAgent = () => pick().name;
|
|
46
|
+
|
|
47
|
+
module.exports = { drive, tryResolvePermission, activeAgent };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
// Claude Code stores session JSONLs at:
|
|
8
|
+
// ~/.claude/projects/<encodedAbsProjectDir>/<sessionId>.jsonl
|
|
9
|
+
// where the encoded dir is the absolute cwd with path separators flattened
|
|
10
|
+
// to `-`:
|
|
11
|
+
// macOS/Linux /Users/x/proj -> -Users-x-proj
|
|
12
|
+
// Windows C:\Users\x\proj -> C--Users-x-proj (drive colon + `\`)
|
|
13
|
+
// NOTE: confirm against Claude Code's own Windows encoding before treating
|
|
14
|
+
// this as load-bearing. The daemon only uses it for the (default-off)
|
|
15
|
+
// freshness guard and `obto-bridge status` — resume goes through the SDK by
|
|
16
|
+
// sessionId, not this path — so a Windows mismatch is non-fatal.
|
|
17
|
+
const encodeProjectDir = (absDir) => String(absDir).replace(/[/\\:]/g, '-');
|
|
18
|
+
|
|
19
|
+
const projectHashDir = (absDir) =>
|
|
20
|
+
path.join(os.homedir(), '.claude', 'projects', encodeProjectDir(absDir));
|
|
21
|
+
|
|
22
|
+
// Returns { sessionId, jsonlPath, mtimeMs } for the most-recently-modified
|
|
23
|
+
// session JSONL in the project, or null if the project dir doesn't exist
|
|
24
|
+
// or has no sessions.
|
|
25
|
+
const scanLatestSession = (absProjectDir) => {
|
|
26
|
+
const dir = projectHashDir(absProjectDir);
|
|
27
|
+
let entries;
|
|
28
|
+
try {
|
|
29
|
+
entries = fs.readdirSync(dir);
|
|
30
|
+
} catch (_) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let best = null;
|
|
35
|
+
for (const name of entries) {
|
|
36
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
37
|
+
const full = path.join(dir, name);
|
|
38
|
+
let st;
|
|
39
|
+
try {
|
|
40
|
+
st = fs.statSync(full);
|
|
41
|
+
} catch (_) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (!st.isFile()) continue;
|
|
45
|
+
const sessionId = name.slice(0, -'.jsonl'.length);
|
|
46
|
+
if (!best || st.mtimeMs > best.mtimeMs) {
|
|
47
|
+
best = { sessionId, jsonlPath: full, mtimeMs: st.mtimeMs };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return best;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
module.exports = { encodeProjectDir, projectHashDir, scanLatestSession };
|
package/src/state.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
const STATE_DIR = path.join(os.homedir(), '.agent-bridge-daemon');
|
|
8
|
+
const STATE_PATH = path.join(STATE_DIR, 'state.json');
|
|
9
|
+
|
|
10
|
+
const ensureDir = () => {
|
|
11
|
+
try {
|
|
12
|
+
fs.mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
|
|
13
|
+
} catch (_) {}
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Schema:
|
|
17
|
+
// {
|
|
18
|
+
// "bindings": {
|
|
19
|
+
// "<threadId>": {
|
|
20
|
+
// "sessionId": "<uuid>",
|
|
21
|
+
// "projectDir": "/abs/path",
|
|
22
|
+
// "jsonlPath": "/.../<sid>.jsonl",
|
|
23
|
+
// "createdAt": "iso-ts",
|
|
24
|
+
// "lastDriveAt": "iso-ts"
|
|
25
|
+
// }, ...
|
|
26
|
+
// }
|
|
27
|
+
// }
|
|
28
|
+
const loadState = () => {
|
|
29
|
+
let raw;
|
|
30
|
+
try {
|
|
31
|
+
raw = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
|
|
32
|
+
} catch (_) {
|
|
33
|
+
raw = {};
|
|
34
|
+
}
|
|
35
|
+
if (!raw.bindings || typeof raw.bindings !== 'object') {
|
|
36
|
+
raw.bindings = {};
|
|
37
|
+
}
|
|
38
|
+
return raw;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const saveState = (state) => {
|
|
42
|
+
ensureDir();
|
|
43
|
+
fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2), 'utf8');
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const getBinding = (state, threadId) =>
|
|
47
|
+
state.bindings && state.bindings[threadId] ? state.bindings[threadId] : null;
|
|
48
|
+
|
|
49
|
+
const setBinding = (state, threadId, info) => {
|
|
50
|
+
state.bindings[threadId] = info;
|
|
51
|
+
saveState(state);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
module.exports = {
|
|
55
|
+
STATE_DIR,
|
|
56
|
+
STATE_PATH,
|
|
57
|
+
loadState,
|
|
58
|
+
saveState,
|
|
59
|
+
getBinding,
|
|
60
|
+
setBinding,
|
|
61
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Thin SSE consumer for /api/bridge/stream. Uses built-in fetch + the response
|
|
4
|
+
// body's ReadableStream to parse newline-delimited SSE events. Handles
|
|
5
|
+
// reconnection with exponential backoff. No external deps.
|
|
6
|
+
|
|
7
|
+
const parseSseFrame = (frame) => {
|
|
8
|
+
// SSE frame is a sequence of "field: value" lines terminated by a blank line.
|
|
9
|
+
// We care about: `event:`, `data:`, `id:`. Comments (`:`) are ignored.
|
|
10
|
+
const out = { event: 'message', id: null, data: '' };
|
|
11
|
+
const lines = frame.split('\n');
|
|
12
|
+
for (let i = 0; i < lines.length; i++) {
|
|
13
|
+
const line = lines[i];
|
|
14
|
+
if (!line || line.charAt(0) === ':') continue;
|
|
15
|
+
const idx = line.indexOf(':');
|
|
16
|
+
let field, value;
|
|
17
|
+
if (idx === -1) {
|
|
18
|
+
field = line.trim();
|
|
19
|
+
value = '';
|
|
20
|
+
} else {
|
|
21
|
+
field = line.slice(0, idx).trim();
|
|
22
|
+
value = line.slice(idx + 1);
|
|
23
|
+
if (value.charAt(0) === ' ') value = value.slice(1);
|
|
24
|
+
}
|
|
25
|
+
if (field === 'event') out.event = value;
|
|
26
|
+
else if (field === 'id') out.id = value;
|
|
27
|
+
else if (field === 'data') {
|
|
28
|
+
out.data = out.data ? out.data + '\n' + value : value;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Connect once, yield SSE events to the onEvent callback. Returns an
|
|
35
|
+
// AbortController so the caller can cancel.
|
|
36
|
+
const connectOnce = async ({ url, headers, onEvent, onError, log }) => {
|
|
37
|
+
const ac = new AbortController();
|
|
38
|
+
const res = await fetch(url, {
|
|
39
|
+
method: 'GET',
|
|
40
|
+
headers: Object.assign({ Accept: 'text/event-stream' }, headers || {}),
|
|
41
|
+
signal: ac.signal,
|
|
42
|
+
cache: 'no-store',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
const err = new Error('SSE connect failed: HTTP ' + res.status);
|
|
47
|
+
err.status = res.status;
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
if (!res.body) {
|
|
51
|
+
throw new Error('SSE connect: no response body');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (log) log('info', 'sse stream connected', { status: res.status });
|
|
55
|
+
|
|
56
|
+
// Read the body as a stream of UTF-8 chunks; split on blank lines (frame
|
|
57
|
+
// delimiter per SSE spec). Pump until end-of-stream or abort.
|
|
58
|
+
(async () => {
|
|
59
|
+
const reader = res.body.getReader();
|
|
60
|
+
const decoder = new TextDecoder('utf-8');
|
|
61
|
+
let buf = '';
|
|
62
|
+
try {
|
|
63
|
+
while (true) {
|
|
64
|
+
const { value, done } = await reader.read();
|
|
65
|
+
if (done) break;
|
|
66
|
+
buf += decoder.decode(value, { stream: true });
|
|
67
|
+
|
|
68
|
+
// Split on \n\n (or \r\n\r\n) — SSE frame delimiter.
|
|
69
|
+
let i;
|
|
70
|
+
while ((i = buf.indexOf('\n\n')) !== -1 || (i = buf.indexOf('\r\n\r\n')) !== -1) {
|
|
71
|
+
const sep = buf.indexOf('\r\n\r\n') !== -1 && buf.indexOf('\r\n\r\n') === i ? 4 : 2;
|
|
72
|
+
const frame = buf.slice(0, i);
|
|
73
|
+
buf = buf.slice(i + sep);
|
|
74
|
+
if (frame.trim().length === 0) continue;
|
|
75
|
+
try {
|
|
76
|
+
onEvent(parseSseFrame(frame));
|
|
77
|
+
} catch (e) {
|
|
78
|
+
if (log) log('error', 'sse frame handler threw', { error: e && e.message });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (onError) onError(new Error('stream ended'));
|
|
83
|
+
} catch (err) {
|
|
84
|
+
if (err && err.name === 'AbortError') return;
|
|
85
|
+
if (onError) onError(err);
|
|
86
|
+
}
|
|
87
|
+
})();
|
|
88
|
+
|
|
89
|
+
return ac;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Persistent connection with exponential backoff on disconnect.
|
|
93
|
+
// `getHeaders` is a (sync) function — called fresh on every (re)connect so
|
|
94
|
+
// rotated tokens are picked up automatically.
|
|
95
|
+
const startStream = ({ url, getHeaders, headers, onEvent, log }) => {
|
|
96
|
+
let stopped = false;
|
|
97
|
+
let backoff = 1000;
|
|
98
|
+
let currentAc = null;
|
|
99
|
+
|
|
100
|
+
// Back-compat: callers can pass a static `headers` object OR a `getHeaders`
|
|
101
|
+
// callback. The callback wins; otherwise we wrap the static object.
|
|
102
|
+
const resolveHeaders = typeof getHeaders === 'function'
|
|
103
|
+
? getHeaders
|
|
104
|
+
: () => headers;
|
|
105
|
+
|
|
106
|
+
const loop = async () => {
|
|
107
|
+
while (!stopped) {
|
|
108
|
+
try {
|
|
109
|
+
currentAc = await connectOnce({
|
|
110
|
+
url,
|
|
111
|
+
headers: resolveHeaders(),
|
|
112
|
+
onEvent,
|
|
113
|
+
onError: (err) => {
|
|
114
|
+
if (log) log('warn', 'sse stream lost', { error: err && err.message });
|
|
115
|
+
try { currentAc && currentAc.abort(); } catch (_) {}
|
|
116
|
+
},
|
|
117
|
+
log,
|
|
118
|
+
});
|
|
119
|
+
backoff = 1000;
|
|
120
|
+
// Wait until the connection ends. We don't have a clean signal from
|
|
121
|
+
// connectOnce for "stream closed" so we busy-poll the AbortController.
|
|
122
|
+
while (!stopped && currentAc && !currentAc.signal.aborted) {
|
|
123
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
124
|
+
}
|
|
125
|
+
} catch (err) {
|
|
126
|
+
if (log) log('error', 'sse connect failed', { error: err && err.message, backoffMs: backoff });
|
|
127
|
+
}
|
|
128
|
+
if (stopped) break;
|
|
129
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
130
|
+
backoff = Math.min(backoff * 2, 30000);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
loop();
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
stop: () => {
|
|
138
|
+
stopped = true;
|
|
139
|
+
try { currentAc && currentAc.abort(); } catch (_) {}
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
module.exports = { startStream, parseSseFrame };
|