nattermatic 0.0.1 → 0.2.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/LICENSE +21 -0
- package/README.md +228 -4
- package/SECURITY.md +178 -0
- package/bin/natter.js +191 -0
- package/install.sh +229 -0
- package/package.json +62 -6
- package/src/cert.js +86 -0
- package/src/claude.js +406 -0
- package/src/cli.js +223 -0
- package/src/codex.js +247 -0
- package/src/config.js +280 -0
- package/src/crypto.js +124 -0
- package/src/discovery.js +60 -0
- package/src/legacy-env.js +4 -0
- package/src/legacy.js +100 -0
- package/src/local.js +193 -0
- package/src/mcp.js +370 -0
- package/src/node.js +1387 -0
- package/src/roster.js +517 -0
- package/src/self.js +41 -0
- package/src/setup.js +451 -0
- package/src/store.js +39 -0
- package/src/tls.js +229 -0
- package/src/types.d.ts +150 -0
- package/src/wake.js +143 -0
package/src/discovery.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// LAN discovery by encrypted UDP broadcast beacons. Deliberately does NOT use mDNS/Bonjour, so it
|
|
2
|
+
// can never interfere with the OS's own hostname announcements.
|
|
3
|
+
import dgram from 'node:dgram'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import { sealBeacon, openBeacon } from './crypto.js'
|
|
6
|
+
|
|
7
|
+
const EVERY = 30_000
|
|
8
|
+
const MAX_SKEW = 5 * 60_000
|
|
9
|
+
const MAX_KEYS_TRIED = 64 // anyone can send a packet; a machine on an older key is found other ways
|
|
10
|
+
|
|
11
|
+
function broadcastAddrs() {
|
|
12
|
+
const out = new Set(['255.255.255.255'])
|
|
13
|
+
for (const list of Object.values(os.networkInterfaces())) {
|
|
14
|
+
for (const i of list || []) {
|
|
15
|
+
if (i.family !== 'IPv4' || i.internal || !i.netmask) continue
|
|
16
|
+
const ip = i.address.split('.').map(Number), mask = i.netmask.split('.').map(Number)
|
|
17
|
+
out.add(ip.map((o, k) => (o | (~mask[k] & 255)) >>> 0).join('.'))
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return [...out]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* onPeer({host, fp, addr}) is called for each beacon from another member. Beacons are encrypted
|
|
25
|
+
* under the network's discovery key, so outsiders can neither read nor forge them; but a captured
|
|
26
|
+
* beacon can be replayed and a member could lie, so callers treat addr as a hint and only route to
|
|
27
|
+
* it once a TLS handshake there presents that member's key.
|
|
28
|
+
* @param {import('./types.js').Config} cfg
|
|
29
|
+
* @param {() => string[]} keys discovery keys to accept, current (used for sending) last
|
|
30
|
+
* @param {(peer: { host: string, fp: string, addr: string }) => void} onPeer
|
|
31
|
+
* @param {(...a: any[]) => void} log
|
|
32
|
+
*/
|
|
33
|
+
export function startDiscovery(cfg, keys, onPeer, log) {
|
|
34
|
+
const port = cfg.discoveryPort || cfg.port
|
|
35
|
+
const sock = dgram.createSocket({ type: 'udp4', reuseAddr: true })
|
|
36
|
+
/** @param {boolean} reply */
|
|
37
|
+
const beacon = reply => {
|
|
38
|
+
const dk = keys().at(-1)
|
|
39
|
+
return dk ? sealBeacon(dk, { host: cfg.host, fp: cfg.identity.fp, port: cfg.port, ts: Date.now(), reply }) : null
|
|
40
|
+
}
|
|
41
|
+
const announce = () => {
|
|
42
|
+
const msg = beacon(false)
|
|
43
|
+
if (msg) for (const a of broadcastAddrs()) sock.send(msg, port, a, () => {})
|
|
44
|
+
}
|
|
45
|
+
sock.on('message', (buf, rinfo) => {
|
|
46
|
+
const b = openBeacon(keys().slice(-MAX_KEYS_TRIED), buf)
|
|
47
|
+
if (!b || typeof b.host !== 'string' || typeof b.fp !== 'string' || b.fp === cfg.identity.fp) return
|
|
48
|
+
if (!Number.isFinite(b.ts) || Math.abs(Date.now() - b.ts) > MAX_SKEW) return
|
|
49
|
+
if (!Number.isInteger(b.port) || b.port <= 0 || b.port >= 65536) return
|
|
50
|
+
if (!b.reply) { const r = beacon(true); if (r) sock.send(r, port, rinfo.address, () => {}) } // answer so discovery is instant both ways
|
|
51
|
+
onPeer({ host: b.host, fp: b.fp, addr: `${rinfo.address}:${b.port}` })
|
|
52
|
+
})
|
|
53
|
+
sock.on('error', e => log('discovery socket error:', e.message))
|
|
54
|
+
sock.bind(port, () => {
|
|
55
|
+
sock.setBroadcast(true)
|
|
56
|
+
announce()
|
|
57
|
+
})
|
|
58
|
+
const t = setInterval(announce, EVERY)
|
|
59
|
+
return { announce, stop() { clearInterval(t); try { sock.close() } catch {} } }
|
|
60
|
+
}
|
package/src/legacy.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// nattermatic was called agentlink until 0.2.0. This moves a machine set up under the old name
|
|
2
|
+
// onto the new one, keeping its key, network and messages, and keeps the old names working for
|
|
3
|
+
// whatever still uses them: the user's scripts and settings, and sessions started before the upgrade.
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import { spawnSync } from 'node:child_process'
|
|
8
|
+
|
|
9
|
+
export const OLD = 'agentlink'
|
|
10
|
+
/** agentlink's home, where a machine set up before the rename keeps its key and messages. */
|
|
11
|
+
export const oldHome = () => path.join(os.homedir(), '.agentlink')
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* AGENTLINK_* settings still count, under their new names, unless the new name is set too.
|
|
15
|
+
* Run before anything reads the environment.
|
|
16
|
+
*/
|
|
17
|
+
export function legacyEnv() {
|
|
18
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
19
|
+
if (!k.startsWith('AGENTLINK_')) continue
|
|
20
|
+
const n = `NATTERMATIC_${k.slice('AGENTLINK_'.length)}`
|
|
21
|
+
if (!(n in process.env)) process.env[n] = v
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const uid = () => process.getuid?.() ?? 0
|
|
26
|
+
|
|
27
|
+
/** Stop and remove agentlink's background service, so it can't restart the old node. */
|
|
28
|
+
export function removeOldService() {
|
|
29
|
+
if (process.platform === 'darwin') {
|
|
30
|
+
const plist = path.join(os.homedir(), 'Library', 'LaunchAgents', 'com.agentlink.node.plist')
|
|
31
|
+
if (!fs.existsSync(plist)) return false
|
|
32
|
+
spawnSync('launchctl', ['bootout', `gui/${uid()}/com.agentlink.node`], { stdio: 'ignore' })
|
|
33
|
+
fs.rmSync(plist, { force: true })
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
if (process.platform === 'linux') {
|
|
37
|
+
const unit = path.join(os.homedir(), '.config', 'systemd', 'user', 'agentlink.service')
|
|
38
|
+
if (!fs.existsSync(unit)) return false
|
|
39
|
+
spawnSync('systemctl', ['--user', 'disable', '--now', 'agentlink'], { stdio: 'ignore' })
|
|
40
|
+
fs.rmSync(unit, { force: true })
|
|
41
|
+
spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' })
|
|
42
|
+
return true
|
|
43
|
+
}
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Move ~/.agentlink to `home` and leave a link in its place, for sessions and shell lines that
|
|
49
|
+
* still use the old path. Only for the usual home, and only if `home` doesn't exist yet or holds
|
|
50
|
+
* nothing but what the installer has just put there (bin, pkg, node): entries already in `home`
|
|
51
|
+
* win, and what's left of the old home is the old program, which the new one replaces.
|
|
52
|
+
* Returns what happened, or null if there was nothing to move.
|
|
53
|
+
* @param {string} home @returns {string | null}
|
|
54
|
+
*/
|
|
55
|
+
export function moveOldHome(home) {
|
|
56
|
+
const old = oldHome()
|
|
57
|
+
let st
|
|
58
|
+
try { st = fs.lstatSync(old) } catch { return null }
|
|
59
|
+
if (!st.isDirectory() || path.resolve(home) === old) return null
|
|
60
|
+
const installed = new Set(['bin', 'pkg', 'node'])
|
|
61
|
+
/** @type {string[]} */
|
|
62
|
+
let have = []
|
|
63
|
+
try { have = fs.readdirSync(home) } catch {}
|
|
64
|
+
if (have.some(e => !installed.has(e))) return `${old} was left alone: ${home} is already in use`
|
|
65
|
+
removeOldService()
|
|
66
|
+
if (!have.length) {
|
|
67
|
+
fs.rmSync(home, { recursive: true, force: true })
|
|
68
|
+
fs.renameSync(old, home)
|
|
69
|
+
} else {
|
|
70
|
+
for (const e of fs.readdirSync(old)) if (!have.includes(e)) fs.renameSync(path.join(old, e), path.join(home, e))
|
|
71
|
+
fs.rmSync(old, { recursive: true, force: true })
|
|
72
|
+
}
|
|
73
|
+
fs.symlinkSync(home, old)
|
|
74
|
+
return `moved ${old} to ${home}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The database's old file name, renamed once nothing has it open: called by the node after it has
|
|
79
|
+
* the port, so no other node of this machine is running.
|
|
80
|
+
* @param {string} dir
|
|
81
|
+
*/
|
|
82
|
+
export function renameOldDb(dir) {
|
|
83
|
+
const from = path.join(dir, 'agentlink.db'), to = path.join(dir, 'nattermatic.db')
|
|
84
|
+
if (!fs.existsSync(from) || fs.existsSync(to)) return
|
|
85
|
+
for (const ext of ['-wal', '-shm', '']) {
|
|
86
|
+
try { fs.renameSync(from + ext, to + ext) } catch (e) { if (/** @type {any} */ (e).code !== 'ENOENT') throw e }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A permission rule naming agentlink's tools, under its new name. @param {any} rule @returns {any} */
|
|
91
|
+
export const renamedRule = rule => typeof rule === 'string' ? rule.replace(/^mcp__agentlink(?=__|$|\()/, 'mcp__nattermatic') : rule
|
|
92
|
+
|
|
93
|
+
/** agentlink.app, beside the app this runs from: replaced by nattermatic.app. @param {string | null} bundle */
|
|
94
|
+
export function removeOldApp(bundle) {
|
|
95
|
+
if (!bundle) return false
|
|
96
|
+
const old = path.join(path.dirname(bundle), 'agentlink.app')
|
|
97
|
+
if (old === bundle || !fs.existsSync(path.join(old, 'Contents', 'Info.plist'))) return false
|
|
98
|
+
fs.rmSync(old, { recursive: true, force: true })
|
|
99
|
+
return true
|
|
100
|
+
}
|
package/src/local.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Client for the local node's loopback API, plus auto-start of the node.
|
|
2
|
+
//
|
|
3
|
+
// The local API runs over TLS on 127.0.0.1 with the same in-band identity check as connections
|
|
4
|
+
// between machines (see tls.js): the node must prove it holds this machine's key, and so must the
|
|
5
|
+
// client. Only a process that can read this machine's private key (this user) gets in, and a
|
|
6
|
+
// process squatting on the port is refused before it is sent anything.
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import { spawn } from 'node:child_process'
|
|
10
|
+
import crypto from 'node:crypto'
|
|
11
|
+
import { authedRequest, authedRaw, PinError, PROTO } from './tls.js'
|
|
12
|
+
import { cmpVersion, ensureHomeDir, errMsg, rosterPath, sleep, VERSION } from './config.js'
|
|
13
|
+
import { Roster } from './roster.js'
|
|
14
|
+
import { selfCmd } from './claude.js'
|
|
15
|
+
|
|
16
|
+
const MAX_LOCAL_REPLY = 64 << 20 // the most an inbox read can come to: 100 messages of 64 KB, escaped
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The running node is a newer nattermatic than this process: this process (a session's adapter
|
|
20
|
+
* started before an upgrade, say) is the one that is out of date, and must not replace the node.
|
|
21
|
+
*/
|
|
22
|
+
export class StaleError extends Error {}
|
|
23
|
+
|
|
24
|
+
/** Is the node behind `h` a different version from this process, and is it newer? @param {import('./types.js').Health} h */
|
|
25
|
+
export function versionOf(h) {
|
|
26
|
+
const proto = h.proto ?? 0 // nodes from before the protocol number count as older
|
|
27
|
+
const differs = proto !== PROTO || h.version !== VERSION
|
|
28
|
+
const newer = proto !== PROTO ? proto > PROTO : cmpVersion(h.version, VERSION) > 0
|
|
29
|
+
return { differs, newer: differs && newer }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @type {{ stamp: string, dks: string[] } | null} */
|
|
33
|
+
let dkCache = null
|
|
34
|
+
/**
|
|
35
|
+
* The discovery keys that open this machine's node, read fresh (a removal elsewhere rotates them
|
|
36
|
+
* while long-lived clients, like MCP adapters, keep running) but through the roster's own rules, so
|
|
37
|
+
* only keys that count are used. Replaying the roster is only needed when the file has changed.
|
|
38
|
+
* @param {string} [dir] @returns {string[]}
|
|
39
|
+
*/
|
|
40
|
+
function readDks(dir) {
|
|
41
|
+
try {
|
|
42
|
+
const file = rosterPath(dir)
|
|
43
|
+
const st = fs.statSync(file)
|
|
44
|
+
const stamp = `${file} ${st.mtimeMs} ${st.size}`
|
|
45
|
+
if (dkCache?.stamp !== stamp) dkCache = { stamp, dks: Roster.load(file).gateDks() }
|
|
46
|
+
return dkCache.dks
|
|
47
|
+
} catch { return [] }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class Local {
|
|
51
|
+
/** @param {Pick<import('./types.js').Config, 'identity' | 'port' | 'dir'>} cfg */
|
|
52
|
+
constructor(cfg) {
|
|
53
|
+
this.cfg = cfg
|
|
54
|
+
this.addr = `127.0.0.1:${cfg.port}`
|
|
55
|
+
this.accept = new Map([[cfg.identity.fp, crypto.createPublicKey(cfg.identity.key)]])
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @param {{ path: string, method?: 'GET' | 'POST', body?: string, timeout?: number, signal?: AbortSignal }} o */
|
|
59
|
+
// anyProto: after an upgrade the running node may be the previous version; setup must still reach it to stop it
|
|
60
|
+
opts(o) { return { addr: this.addr, identity: this.cfg.identity, accept: this.accept, dks: readDks(this.cfg.dir), anyProto: true, ...o } }
|
|
61
|
+
|
|
62
|
+
/** @param {unknown} e */
|
|
63
|
+
wrap(e) {
|
|
64
|
+
if (e instanceof PinError || /** @type {any} */ (e)?.code === 'EPROTO') return new Error(`port ${this.cfg.port} is not answered by this machine's nattermatic node`)
|
|
65
|
+
return new Error(`cannot reach the nattermatic node on port ${this.cfg.port}: ${errMsg(e)}`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Request to the local node's API; throws with the node's error message on failure.
|
|
70
|
+
* @param {'GET'|'POST'} method
|
|
71
|
+
* @param {string} p
|
|
72
|
+
* @param {object} [obj]
|
|
73
|
+
* @returns {Promise<any>}
|
|
74
|
+
*/
|
|
75
|
+
async req(method, p, obj, query = '', timeout = 15_000) {
|
|
76
|
+
let r
|
|
77
|
+
try {
|
|
78
|
+
r = await authedRequest(this.opts({ method, path: p + query, body: obj === undefined ? '' : JSON.stringify(obj), timeout }), MAX_LOCAL_REPLY)
|
|
79
|
+
} catch (e) { throw this.wrap(e) }
|
|
80
|
+
/** @type {any} */
|
|
81
|
+
let data = {}
|
|
82
|
+
try { data = r.text ? JSON.parse(r.text) : {} } catch {}
|
|
83
|
+
if (r.status < 200 || r.status >= 300) throw new Error(data.error || `HTTP ${r.status}`)
|
|
84
|
+
return data
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Is a node running on the port? null: nothing is listening. { foreign: true }: something that
|
|
89
|
+
* isn't this machine's node (another network's node, another user's, or not nattermatic at all).
|
|
90
|
+
* @returns {Promise<import('./types.js').Health | { foreign: true } | null>}
|
|
91
|
+
*/
|
|
92
|
+
async health() {
|
|
93
|
+
try {
|
|
94
|
+
const r = await authedRequest(this.opts({ path: '/health', timeout: 1000 }))
|
|
95
|
+
return r.status === 200 ? JSON.parse(r.text) : { foreign: true }
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return e instanceof PinError || /** @type {any} */ (e)?.code === 'EPROTO' ? { foreign: true } : null
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Make sure a node is running on this machine (start one in the background if not). */
|
|
102
|
+
async ensureNode() {
|
|
103
|
+
let h = await this.health()
|
|
104
|
+
if (h && 'foreign' in h) throw new Error(`port ${this.cfg.port} is used by something that is not this machine's nattermatic node`)
|
|
105
|
+
if (h && versionOf(h).newer) {
|
|
106
|
+
// nattermatic was upgraded while this process was running: replacing the node would downgrade it
|
|
107
|
+
throw new StaleError(`nattermatic on this machine was upgraded (to ${h.version}, protocol ${h.proto}) while this program (${VERSION}, protocol ${PROTO}) was running, so it can't use the node any more`)
|
|
108
|
+
}
|
|
109
|
+
// nattermatic was upgraded under a running node: replace it with this version
|
|
110
|
+
if (h && versionOf(h).differs) {
|
|
111
|
+
try { await this.req('POST', '/local/shutdown') } catch {}
|
|
112
|
+
for (let i = 0; i < 30 && (h = await this.health()) && !('foreign' in h) && versionOf(h).differs; i++) await sleep(100)
|
|
113
|
+
}
|
|
114
|
+
if (h && !('foreign' in h) && !versionOf(h).differs) return h
|
|
115
|
+
if (fs.existsSync(path.join(this.cfg.dir, 'disabled'))) throw new Error('nattermatic is uninstalled on this machine (run `natter setup` to re-enable)')
|
|
116
|
+
ensureHomeDir(this.cfg.dir)
|
|
117
|
+
const out = fs.openSync(path.join(this.cfg.dir, 'node.log'), 'a', 0o600)
|
|
118
|
+
let exited = false, spawnErr = ''
|
|
119
|
+
try {
|
|
120
|
+
// --on-demand: if another node wins the race for the port, this one exits instead of standing by.
|
|
121
|
+
// selfCmd(): a Node path that survives upgrades (the versioned execPath may be removed by brew)
|
|
122
|
+
const [cmd, ...args] = selfCmd()
|
|
123
|
+
const child = spawn(cmd, [...args, 'node', '--on-demand'], { detached: true, stdio: ['ignore', out, out], env: process.env })
|
|
124
|
+
child.on('error', e => { exited = true; spawnErr = errMsg(e) }) // e.g. ENOENT: must not crash the caller
|
|
125
|
+
child.on('exit', () => { exited = true })
|
|
126
|
+
child.unref()
|
|
127
|
+
} finally { fs.closeSync(out) }
|
|
128
|
+
for (let i = 0; i < 60; i++) {
|
|
129
|
+
await sleep(100)
|
|
130
|
+
const h2 = await this.health()
|
|
131
|
+
if (h2 && !('foreign' in h2)) return h2
|
|
132
|
+
if (exited && i > 5) break // it died (lost the race to a node still starting, or failed): one last look below
|
|
133
|
+
}
|
|
134
|
+
const h3 = await this.health()
|
|
135
|
+
if (h3 && !('foreign' in h3)) return h3
|
|
136
|
+
throw new Error(`could not start the nattermatic node${spawnErr ? ` (${spawnErr})` : ''} (see ${path.join(this.cfg.dir, 'node.log')})`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Wait for news for the session owned by one of `pids` (and, given `cwd`, working in that folder).
|
|
141
|
+
* null: there's no such session (yet).
|
|
142
|
+
* @param {number[]} pids @param {string} [cwd]
|
|
143
|
+
* @returns {Promise<{ event: string, name?: string, count?: number } | null>}
|
|
144
|
+
*/
|
|
145
|
+
async wakeWait(pids, cwd) {
|
|
146
|
+
const { res } = await authedRaw(this.opts({ timeout: 0, path: `/local/wake?pids=${pids.join(',')}${cwd ? `&cwd=${encodeURIComponent(cwd)}` : ''}` }))
|
|
147
|
+
if (res.statusCode !== 200) { res.destroy(); return null }
|
|
148
|
+
for await (const { event, data } of sseEvents(res)) { res.destroy(); return { event, ...data } }
|
|
149
|
+
return { event: 'closed' }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Long-lived stream of messages for session `name`; `token` lets a reconnect reclaim the same name.
|
|
154
|
+
* Resolves when the stream ends.
|
|
155
|
+
* @param {string} name
|
|
156
|
+
* @param {string} token
|
|
157
|
+
* @param {(event: string, data: any) => Promise<void> | void} onEvent
|
|
158
|
+
* @param {{ signal?: AbortSignal, poll?: boolean }} [opts] poll: messages are read with the inbox tool, not pushed
|
|
159
|
+
*/
|
|
160
|
+
async stream(name, token, onEvent, { signal, poll = false } = {}) {
|
|
161
|
+
let res
|
|
162
|
+
try {
|
|
163
|
+
({ res } = await authedRaw(this.opts({
|
|
164
|
+
timeout: 0, signal,
|
|
165
|
+
// owner and cwd: the session that started this adapter, so its hook can find it: Claude Code's
|
|
166
|
+
// process (see wake.js), or Codex's shared app-server and the session's folder (see codex.js)
|
|
167
|
+
path: `/local/stream?name=${encodeURIComponent(name)}&token=${encodeURIComponent(token)}&owner=${process.ppid}&cwd=${encodeURIComponent(process.cwd())}${poll ? '&mode=poll' : ''}`,
|
|
168
|
+
})))
|
|
169
|
+
} catch (e) { throw /** @type {any} */ (e)?.name === 'AbortError' ? e : this.wrap(e) }
|
|
170
|
+
if (res.statusCode !== 200) { res.destroy(); throw new Error(`stream HTTP ${res.statusCode}`) }
|
|
171
|
+
for await (const { event, data } of sseEvents(res)) await onEvent(event, data)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The events of a server-sent stream, as they arrive. @param {import('node:http').IncomingMessage} res */
|
|
176
|
+
async function* sseEvents(res) {
|
|
177
|
+
const dec = new TextDecoder()
|
|
178
|
+
let buf = ''
|
|
179
|
+
for await (const chunk of res) {
|
|
180
|
+
buf += dec.decode(chunk, { stream: true })
|
|
181
|
+
let i
|
|
182
|
+
while ((i = buf.indexOf('\n\n')) >= 0) {
|
|
183
|
+
const raw = buf.slice(0, i)
|
|
184
|
+
buf = buf.slice(i + 2)
|
|
185
|
+
let event = 'message', data = ''
|
|
186
|
+
for (const line of raw.split('\n')) {
|
|
187
|
+
if (line.startsWith('event: ')) event = line.slice(7)
|
|
188
|
+
else if (line.startsWith('data: ')) data += line.slice(6)
|
|
189
|
+
}
|
|
190
|
+
if (data) yield { event, data: JSON.parse(data) }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|