dsh-ris-yds 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +160 -2
- package/assets/yds-chrome-resource-override.crx +0 -0
- package/bin/check-listing.mjs +247 -0
- package/bin/yds.js +19 -0
- package/cordis.patch.yml +13 -0
- package/lib/browser.js +428 -0
- package/lib/build.js +115 -0
- package/lib/cli.js +1130 -0
- package/lib/confcenter.js +377 -0
- package/lib/config.js +552 -0
- package/lib/fe-dev.js +206 -0
- package/lib/ide-proxy.js +906 -0
- package/lib/ide-watch.js +188 -0
- package/lib/ide.js +728 -0
- package/lib/index.js +267 -0
- package/lib/proc.js +270 -0
- package/lib/risd-peer.js +353 -0
- package/lib/rules.js +193 -0
- package/lib/socks.js +279 -0
- package/lib/token.js +36 -0
- package/lib/yms.js +336 -0
- package/lib/yoncode.js +249 -0
- package/native/yds-rust.darwin-arm64.node +0 -0
- package/native/yds-rust.darwin-x64.node +0 -0
- package/native/yds-rust.linux-arm64.node +0 -0
- package/native/yds-rust.linux-x64.node +0 -0
- package/native/yds-rust.win32-arm64.node +0 -0
- package/native/yds-rust.win32-x64.node +0 -0
- package/package.json +58 -4
- package/skills/dsh-ris-yds/SKILL.md +134 -0
- package/test/browser.test.mjs +442 -0
- package/test/cli-stop.test.mjs +40 -0
- package/test/cli.test.mjs +246 -0
- package/test/config.test.mjs +106 -0
- package/test/fe-dev.test.mjs +93 -0
- package/test/hints.test.mjs +13 -0
- package/test/ide-config.test.mjs +155 -0
- package/test/ide-proxy.test.mjs +256 -0
- package/test/ide-watch.test.mjs +79 -0
- package/test/launch-proxy.test.mjs +83 -0
- package/test/lease-release.test.mjs +76 -0
- package/test/mode-separation.test.mjs +147 -0
- package/test/pidfile.test.mjs +105 -0
- package/test/proc.test.mjs +172 -0
- package/test/proxy-standalone.test.mjs +121 -0
- package/test/risd-leases.test.mjs +111 -0
- package/test/risd-tier.test.mjs +361 -0
- package/test/rules-e2e.test.mjs +122 -0
- package/test/rules.test.mjs +154 -0
- package/test/yoncode.test.mjs +159 -0
package/lib/socks.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// SOCKS forwarder: local TCP listeners that tunnel through a SOCKS5 proxy to
|
|
2
|
+
// the real DB/Redis. Only started when .env carries socks.host;
|
|
3
|
+
// without it everything connects directly (requirement 2).
|
|
4
|
+
//
|
|
5
|
+
// The forwarder runs as its own detached process (`yds socks-fwd`) with a
|
|
6
|
+
// pidfile in .dsh-ris-yds/; start/stop are idempotent.
|
|
7
|
+
|
|
8
|
+
import { spawn } from 'node:child_process'
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
|
|
10
|
+
import { pidAlive, pidIdentity, stopPid } from './proc.js'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import net from 'node:net'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
|
|
15
|
+
export function socksPidFile(cfg) {
|
|
16
|
+
return join(cfg.stateDir, 'socks-forward.pid')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Verify a pid is really our process (guards against pid reuse after a
|
|
21
|
+
* crash/reboot: a bare pid from a stale pidfile could be an unrelated
|
|
22
|
+
* process — never signal it). Identity = the pid's command line contains our
|
|
23
|
+
* marker, from /proc on Linux/WSL or the Win32 process table on Windows.
|
|
24
|
+
* `unknown` (probe failed) is NOT "not ours": see pidIdentity (review D-P1-6).
|
|
25
|
+
*/
|
|
26
|
+
export function pidIsOurs(pid, marker) {
|
|
27
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
28
|
+
return pidIdentity(pid, [marker]).state === 'ours'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const FORWARDER_MARKER = 'socks-fwd'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Read + verify the forwarder pidfile.
|
|
35
|
+
* @returns {{pid: number|null, state: 'absent'|'verified'|'stale'|'unknown', reason: string}}
|
|
36
|
+
* Only `verified` yields a pid. `unknown` keeps the pidfile untouched so a
|
|
37
|
+
* transient probe failure cannot cause a duplicate forwarder.
|
|
38
|
+
*/
|
|
39
|
+
export function probeForwarder(cfg, options = {}) {
|
|
40
|
+
const pidFile = socksPidFile(cfg)
|
|
41
|
+
if (!existsSync(pidFile)) return { pid: null, state: 'absent', reason: 'pidfile 不存在' }
|
|
42
|
+
const pid = Number(readFileSync(pidFile, 'utf8').trim())
|
|
43
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
44
|
+
try { unlinkSync(pidFile) } catch { /* ignore */ }
|
|
45
|
+
return { pid: null, state: 'stale', reason: 'pidfile 内容无效(已清理)' }
|
|
46
|
+
}
|
|
47
|
+
const identity = pidIdentity(pid, [FORWARDER_MARKER], options)
|
|
48
|
+
if (identity.state === 'ours') return { pid, state: 'verified', reason: '' }
|
|
49
|
+
if (identity.state === 'dead') {
|
|
50
|
+
try { unlinkSync(pidFile) } catch { /* ignore */ }
|
|
51
|
+
return { pid: null, state: 'stale', reason: '转发器进程已退出(已清理 pidfile)' }
|
|
52
|
+
}
|
|
53
|
+
if (identity.state === 'foreign') {
|
|
54
|
+
// pid reused by an unrelated process after a crash/reboot: the pidfile is stale.
|
|
55
|
+
try { unlinkSync(pidFile) } catch { /* ignore */ }
|
|
56
|
+
return { pid: null, state: 'stale', reason: 'pid 已被其它进程复用(已清理 pidfile)' }
|
|
57
|
+
}
|
|
58
|
+
return { pid: null, state: 'unknown', reason: '无法确认 pidfile 进程身份(探测失败),已保留 pidfile 未动' }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Verified forwarder pid, or null (absent/stale/foreign/unknown). */
|
|
62
|
+
export function forwarderPid(cfg, options = {}) {
|
|
63
|
+
return probeForwarder(cfg, options).pid
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function portOpen(host, port, timeout = 1500) {
|
|
67
|
+
return new Promise((resolve_) => {
|
|
68
|
+
const sock = net.connect({ host, port })
|
|
69
|
+
const done = (ok) => { sock.destroy(); resolve_(ok) }
|
|
70
|
+
sock.setTimeout(timeout, () => done(false))
|
|
71
|
+
sock.once('connect', () => done(true))
|
|
72
|
+
sock.once('error', () => done(false))
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Our forwarder is "up" only when the pidfile pid is alive AND is ours AND
|
|
78
|
+
* the port answers. A port answering without our pid = some other local
|
|
79
|
+
* service (e.g. a local Oracle on 1521) — NOT the tunnel.
|
|
80
|
+
*/
|
|
81
|
+
export function isForwarderUp(cfg, options = {}) {
|
|
82
|
+
return (async () => {
|
|
83
|
+
const probe = probeForwarder(cfg, options)
|
|
84
|
+
// Identity unreadable (`unknown`): fall back to the listeners, because starting a
|
|
85
|
+
// second forwarder next to a live one is worse than a possible false positive.
|
|
86
|
+
if (probe.state === 'unknown') {
|
|
87
|
+
return (await portOpen('127.0.0.1', cfg.dbForwardPort, 400)) || (await portOpen('127.0.0.1', cfg.redisForwardPort, 400))
|
|
88
|
+
}
|
|
89
|
+
if (probe.pid === null) return false
|
|
90
|
+
return (await portOpen('127.0.0.1', cfg.dbForwardPort, 400)) || (await portOpen('127.0.0.1', cfg.redisForwardPort, 400))
|
|
91
|
+
})()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Spawn the detached forwarder process (idempotent). `targets` come from the
|
|
96
|
+
* confcenter adaptation: [{ host, port, localPort?, isRedis? }] — an explicit
|
|
97
|
+
* localPort (yonbip extras: kafka/ES/license keep their own port number)
|
|
98
|
+
* wins, db gets dbForwardPort, redis gets redisForwardPort.
|
|
99
|
+
*/
|
|
100
|
+
export async function startForwarder(cfg, targets, { logPath } = {}) {
|
|
101
|
+
if (cfg.socks === null) return { started: false, reason: 'socks 未配置(直连模式,无需转发)' }
|
|
102
|
+
if (await isForwarderUp(cfg)) {
|
|
103
|
+
return { started: false, reason: '转发器已在监听' }
|
|
104
|
+
}
|
|
105
|
+
await stopForwarder(cfg)
|
|
106
|
+
mkdirSync(cfg.stateDir, { recursive: true })
|
|
107
|
+
const specs = []
|
|
108
|
+
for (const t of targets) {
|
|
109
|
+
const localPort = Number.isInteger(t.localPort) ? t.localPort
|
|
110
|
+
: (t.isRedis === true ? cfg.redisForwardPort : cfg.dbForwardPort)
|
|
111
|
+
if (specs.some((s) => s.local === localPort)) continue
|
|
112
|
+
specs.push({ local: localPort, host: t.host, port: t.port })
|
|
113
|
+
}
|
|
114
|
+
if (specs.length === 0) specs.push({ local: cfg.dbForwardPort, host: '', port: 0 }) // never used
|
|
115
|
+
const args = specs.map((s) => `${s.local}:${s.host}:${s.port}`)
|
|
116
|
+
const bin = fileURLToPath(new URL('../bin/yds.js', import.meta.url))
|
|
117
|
+
const out = logPath ?? join(cfg.stateDir, 'socks-forward.log')
|
|
118
|
+
const { openSync } = await import('node:fs')
|
|
119
|
+
const outFd = openSync(out, 'a')
|
|
120
|
+
const child = spawn(process.execPath, [bin, 'socks-fwd', `--socks=${cfg.socks.host}:${cfg.socks.port}`, ...args], {
|
|
121
|
+
detached: true,
|
|
122
|
+
stdio: ['ignore', outFd, outFd],
|
|
123
|
+
env: process.env,
|
|
124
|
+
})
|
|
125
|
+
child.unref()
|
|
126
|
+
writeFileSync(socksPidFile(cfg), String(child.pid), 'utf8')
|
|
127
|
+
const portsUp = async () => {
|
|
128
|
+
// every spec must listen; report the first one that does not
|
|
129
|
+
for (const s of specs) {
|
|
130
|
+
// eslint-disable-next-line no-await-in-loop
|
|
131
|
+
if (!(await portOpen('127.0.0.1', s.local, 150))) return s.local
|
|
132
|
+
}
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
for (let i = 0; i < 40; i += 1) {
|
|
136
|
+
// eslint-disable-next-line no-await-in-loop
|
|
137
|
+
const down = await portsUp()
|
|
138
|
+
if (down === null) break
|
|
139
|
+
// eslint-disable-next-line no-await-in-loop
|
|
140
|
+
await new Promise((r) => setTimeout(r, 200))
|
|
141
|
+
}
|
|
142
|
+
// the wait loop above can exhaust without the port ever answering (child
|
|
143
|
+
// crashed instantly, e.g. EADDRINUSE) — verify before reporting success.
|
|
144
|
+
const down = await portsUp()
|
|
145
|
+
const alive = pidAlive(child.pid)
|
|
146
|
+
if (down !== null || !alive) {
|
|
147
|
+
try { unlinkSync(socksPidFile(cfg)) } catch { /* ignore */ }
|
|
148
|
+
throw new Error(`socks 转发器启动失败(端口 ${down ?? '?'} 未监听 / 进程存活=${alive})——详情见 ${out}(端口被占或其他本地服务在 127.0.0.1:${down}?)`)
|
|
149
|
+
}
|
|
150
|
+
return { started: true, pid: child.pid, targets: args, log: out }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Stop the detached forwarder if its pidfile pid is ours (never kill a reused foreign pid). */
|
|
154
|
+
export async function stopForwarder(cfg, options = {}) {
|
|
155
|
+
const probe = probeForwarder(cfg, options)
|
|
156
|
+
if (probe.pid === null) return { stopped: false, reason: probe.reason }
|
|
157
|
+
const pidFile = socksPidFile(cfg)
|
|
158
|
+
const killed = await stopPid(probe.pid, { graceMs: 5000, ...options })
|
|
159
|
+
if (killed.ok !== true) {
|
|
160
|
+
// Keep the pidfile: the process is still there and `yds status` must keep seeing it
|
|
161
|
+
// (review D-P1-7).
|
|
162
|
+
return { stopped: false, pid: probe.pid, reason: `停止失败:${killed.error ?? '未知原因'}(已保留 pidfile)` }
|
|
163
|
+
}
|
|
164
|
+
try { unlinkSync(pidFile) } catch { /* ignore */ }
|
|
165
|
+
return { stopped: true, pid: probe.pid }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** SOCKS5 no-auth handshake + CONNECT, promise-based (proven implementation). */
|
|
169
|
+
const SOCKS_CONNECT_TIMEOUT_MS = 15000
|
|
170
|
+
|
|
171
|
+
function connectSocks(targetHost, targetPort, socksHost, socksPort) {
|
|
172
|
+
return new Promise((resolve_, reject_) => {
|
|
173
|
+
const sock = net.connect(socksPort, socksHost)
|
|
174
|
+
const timer = setTimeout(() => { try { sock.destroy() } catch { /* ignore */ } reject_(new Error('socks connect timeout')) }, SOCKS_CONNECT_TIMEOUT_MS)
|
|
175
|
+
const settle = (fn) => (arg) => { clearTimeout(timer); fn(arg) }
|
|
176
|
+
sock.once('error', settle(reject_))
|
|
177
|
+
sock.once('connect', () => {
|
|
178
|
+
sock.write(Buffer.from([0x05, 0x01, 0x00])) // greeting: no-auth
|
|
179
|
+
sock.once('data', (buf) => {
|
|
180
|
+
if (buf.length < 2 || buf[0] !== 0x05) { sock.destroy(); return reject_(new Error('bad socks greeting')) }
|
|
181
|
+
if (buf[1] !== 0x00) { sock.destroy(); return reject_(new Error('socks requires auth')) }
|
|
182
|
+
const isIp = /^\d+\.\d+\.\d+\.\d+$/.test(targetHost)
|
|
183
|
+
let req
|
|
184
|
+
if (isIp) {
|
|
185
|
+
req = Buffer.alloc(10)
|
|
186
|
+
req[0] = 0x05; req[1] = 0x01; req[3] = 0x01
|
|
187
|
+
targetHost.split('.').forEach((o, i) => { req[4 + i] = Number(o) })
|
|
188
|
+
req.writeUInt16BE(targetPort, 8)
|
|
189
|
+
} else {
|
|
190
|
+
const name = Buffer.from(targetHost, 'utf8')
|
|
191
|
+
req = Buffer.alloc(7 + name.length)
|
|
192
|
+
req[0] = 0x05; req[1] = 0x01; req[3] = 0x03; req[4] = name.length
|
|
193
|
+
name.copy(req, 5)
|
|
194
|
+
req.writeUInt16BE(targetPort, 5 + name.length)
|
|
195
|
+
}
|
|
196
|
+
sock.write(req)
|
|
197
|
+
let acc = Buffer.alloc(0)
|
|
198
|
+
const onReply = (buf) => {
|
|
199
|
+
acc = Buffer.concat([acc, buf])
|
|
200
|
+
if (acc.length < 4) return
|
|
201
|
+
const atyp = acc[3]
|
|
202
|
+
const need = atyp === 0x01 ? 10 : (atyp === 0x03 ? 5 + acc[4] + 2 : (atyp === 0x04 ? 22 : -1))
|
|
203
|
+
if (need < 0 || acc.length < need) return
|
|
204
|
+
sock.removeListener('data', onReply)
|
|
205
|
+
if (acc[1] !== 0x00) { sock.destroy(); return reject_(new Error(`socks CONNECT failed rep=${acc[1]}`)) }
|
|
206
|
+
if (acc.length > need) sock.unshift(acc.subarray(need))
|
|
207
|
+
clearTimeout(timer)
|
|
208
|
+
resolve_(sock)
|
|
209
|
+
}
|
|
210
|
+
sock.on('data', onReply)
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The forwarder loop (runs inside `yds socks-fwd`). */
|
|
217
|
+
/**
|
|
218
|
+
* Health probe: full SOCKS5 handshake + CONNECT to one target. Returns true
|
|
219
|
+
* when the tunnel actually forwards (rep=0). Distinguishes "forwarder
|
|
220
|
+
* listening" from "remote machine can still reach the resource" — the
|
|
221
|
+
* one-way tunnel on the remote dev machine can go down independently.
|
|
222
|
+
*/
|
|
223
|
+
export function probeSocksConnect(socks, target, timeout = 8000) {
|
|
224
|
+
return new Promise((resolve_) => {
|
|
225
|
+
let phase = 0
|
|
226
|
+
let settled = false
|
|
227
|
+
const done = (v) => { if (!settled) { settled = true; resolve_(v) } }
|
|
228
|
+
let socket
|
|
229
|
+
try { socket = net.connect(socks.port, socks.host) } catch { resolve_(false); return }
|
|
230
|
+
socket.setTimeout(timeout, () => { done(false); socket.destroy() })
|
|
231
|
+
socket.on('error', () => done(false))
|
|
232
|
+
socket.on('connect', () => {
|
|
233
|
+
try { socket.write(Buffer.from([5, 1, 0])) } catch { done(false) }
|
|
234
|
+
})
|
|
235
|
+
socket.on('data', (d) => {
|
|
236
|
+
if (settled) return
|
|
237
|
+
if (phase === 0) {
|
|
238
|
+
if (d.length < 2 || d[0] !== 5 || d[1] !== 0) { done(false); socket.destroy(); return }
|
|
239
|
+
phase = 1
|
|
240
|
+
const host = Buffer.from(target.host, 'utf8')
|
|
241
|
+
const port = target.port
|
|
242
|
+
const req = Buffer.from([5, 1, 0, 3, host.length, ...host, Math.floor(port / 256), port % 256])
|
|
243
|
+
try { socket.write(req) } catch { done(false) }
|
|
244
|
+
} else {
|
|
245
|
+
done(d.length >= 2 && d[1] === 0)
|
|
246
|
+
socket.destroy()
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function runForwarder(socksHost, socksPort, specs) {
|
|
253
|
+
console.log(`[yds] socks=${socksHost}:${socksPort}`)
|
|
254
|
+
let failures = 0
|
|
255
|
+
for (const s of specs) {
|
|
256
|
+
const server = net.createServer((client) => {
|
|
257
|
+
connectSocks(s.host, s.port, socksHost, socksPort).then((remote) => {
|
|
258
|
+
client.pipe(remote)
|
|
259
|
+
remote.pipe(client)
|
|
260
|
+
const done = () => { try { client.destroy(); remote.destroy() } catch { /* ignore */ } }
|
|
261
|
+
client.on('error', done); remote.on('error', done)
|
|
262
|
+
client.on('close', done); remote.on('close', done)
|
|
263
|
+
}).catch((e) => {
|
|
264
|
+
console.error(`[yds] ${s.local}->${s.host}:${s.port} failed: ${e.message}`)
|
|
265
|
+
client.destroy()
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
// without this listener EADDRINUSE (or any bind failure) becomes an
|
|
269
|
+
// uncaught exception in the detached child — parent then reports success.
|
|
270
|
+
server.on('error', (e) => {
|
|
271
|
+
failures += 1
|
|
272
|
+
console.error(`[yds] listen 127.0.0.1:${s.local} failed: ${e.message}`)
|
|
273
|
+
if (failures >= specs.length) process.exit(1)
|
|
274
|
+
})
|
|
275
|
+
server.listen(s.local, '127.0.0.1', () => {
|
|
276
|
+
console.log(`[yds] forward 127.0.0.1:${s.local} -> ${s.host}:${s.port}`)
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
}
|
package/lib/token.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// YYCToken generation. The signing key derivation lives in the closed-source
|
|
2
|
+
// yds-rust native module, so we load the prebuilt binary per platform from
|
|
3
|
+
// native/ (darwin/linux/win32 × x64/arm64 — extracted from the YonCode
|
|
4
|
+
// extension, bundled here so the plugin needs no YonCode install).
|
|
5
|
+
|
|
6
|
+
import { existsSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { pathToFileURL } from 'node:url'
|
|
9
|
+
import { PLUGIN_DIR } from './config.js'
|
|
10
|
+
|
|
11
|
+
let cached = null
|
|
12
|
+
|
|
13
|
+
function loadNative() {
|
|
14
|
+
if (cached !== null) return cached
|
|
15
|
+
const file = join(PLUGIN_DIR, 'native', `yds-rust.${process.platform}-${process.arch}.node`)
|
|
16
|
+
if (!existsSync(file)) {
|
|
17
|
+
throw new Error(`当前平台无 yds-rust 原生模块: ${process.platform}-${process.arch} (${file})`)
|
|
18
|
+
}
|
|
19
|
+
cached = { file, mod: null }
|
|
20
|
+
return cached
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Generate a YYCToken for a URL.
|
|
25
|
+
* @param {string} url - the exact request URL the token will authorize.
|
|
26
|
+
* @param {string} accessKey
|
|
27
|
+
* @param {string} accessSecret
|
|
28
|
+
* @returns {Promise<string>} the token (URL-bound; regenerate per URL).
|
|
29
|
+
*/
|
|
30
|
+
export async function getYycToken(url, accessKey, accessSecret) {
|
|
31
|
+
const entry = loadNative()
|
|
32
|
+
if (entry.mod === null) entry.mod = await import(pathToFileURL(entry.file).href) // pathToFileURL: survives spaces on Windows
|
|
33
|
+
const fn = entry.mod.getYycToken ?? entry.mod.default?.getYycToken
|
|
34
|
+
if (typeof fn !== 'function') throw new Error('yds-rust 模块缺少 getYycToken 导出')
|
|
35
|
+
return fn(url, accessKey, accessSecret)
|
|
36
|
+
}
|
package/lib/yms.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// YMS runtime: build the launch (env + JVM flags + rdbga), start in foreground
|
|
2
|
+
// (for the dsh-ris-ide run-with-log wrapper) or detached (own pidfile + logs),
|
|
3
|
+
// stop, status. Ports the proven work/start-yms.sh logic into cross-OS node.
|
|
4
|
+
|
|
5
|
+
import { spawn } from 'node:child_process'
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, openSync, closeSync, statSync, renameSync, readSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import http from 'node:http'
|
|
9
|
+
import { portOpen } from './socks.js'
|
|
10
|
+
import { pidIdentity, stopPid } from './proc.js'
|
|
11
|
+
import { ymsHomeIsBuilt } from './build.js'
|
|
12
|
+
|
|
13
|
+
export function ymsPidFile(cfg) {
|
|
14
|
+
return join(cfg.stateDir, 'yms.pid')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// EXACT flag set from the proven work/start-yms.sh (YonCode launch template).
|
|
18
|
+
// Do NOT edit module/package pairs from memory — wrong module names are
|
|
19
|
+
// silently ignored by the JVM and break Liberty JNDI init (observed:
|
|
20
|
+
// PseudoContext "no JNDI feature is configured" forever, module deploy fails).
|
|
21
|
+
// The last 2 exports + 3 opens come from the YonCode-generated
|
|
22
|
+
// .vscode/launch.json (ensureLaunchConfig) — same provenance, kept in sync.
|
|
23
|
+
const ADD_EXPORTS = [
|
|
24
|
+
'java.base/sun.security.action=ALL-UNNAMED',
|
|
25
|
+
'java.naming/com.sun.jndi.ldap=ALL-UNNAMED',
|
|
26
|
+
'java.naming/com.sun.jndi.url.ldap=ALL-UNNAMED',
|
|
27
|
+
'jdk.naming.dns/com.sun.jndi.dns=ALL-UNNAMED',
|
|
28
|
+
'jdk.naming.dns/com.sun.jndi.url.dns=ALL-UNNAMED',
|
|
29
|
+
'java.security.jgss/sun.security.krb5.internal=ALL-UNNAMED',
|
|
30
|
+
'jdk.attach/sun.tools.attach=ALL-UNNAMED',
|
|
31
|
+
'jdk.management.agent/jdk.internal.agent=ALL-UNNAMED',
|
|
32
|
+
'java.base/jdk.internal.vm=ALL-UNNAMED',
|
|
33
|
+
]
|
|
34
|
+
const ADD_OPENS = [
|
|
35
|
+
'java.base/java.util=ALL-UNNAMED',
|
|
36
|
+
'java.base/java.lang=ALL-UNNAMED',
|
|
37
|
+
'java.base/java.util.concurrent=ALL-UNNAMED',
|
|
38
|
+
'java.base/java.io=ALL-UNNAMED',
|
|
39
|
+
'java.base/java.nio=ALL-UNNAMED',
|
|
40
|
+
'java.base/sun.nio.ch=ALL-UNNAMED',
|
|
41
|
+
'java.naming/javax.naming.spi=ALL-UNNAMED',
|
|
42
|
+
'java.naming/com.sun.naming.internal=ALL-UNNAMED',
|
|
43
|
+
'jdk.naming.rmi/com.sun.jndi.url.rmi=ALL-UNNAMED',
|
|
44
|
+
'java.naming/javax.naming=ALL-UNNAMED',
|
|
45
|
+
'java.rmi/java.rmi=ALL-UNNAMED',
|
|
46
|
+
'java.sql/java.sql=ALL-UNNAMED',
|
|
47
|
+
'java.management/javax.management=ALL-UNNAMED',
|
|
48
|
+
'java.base/java.lang.reflect=ALL-UNNAMED',
|
|
49
|
+
'java.desktop/java.awt.image=ALL-UNNAMED',
|
|
50
|
+
'java.base/java.security=ALL-UNNAMED',
|
|
51
|
+
'java.base/java.net=ALL-UNNAMED',
|
|
52
|
+
'java.base/java.text=ALL-UNNAMED',
|
|
53
|
+
'java.base/sun.net.www.protocol.https=ALL-UNNAMED',
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* rdbga cookie value (base64 of "module,/context@bind:port").
|
|
58
|
+
*
|
|
59
|
+
* Every part is validated: a half-filled config used to produce
|
|
60
|
+
* `base64("null,/@null:31010")`, and browser tools would then happily write that into
|
|
61
|
+
* the workbench — pointing the gateway at an instance that cannot exist. Fail loudly.
|
|
62
|
+
*/
|
|
63
|
+
export function rdbgaValue(cfg) {
|
|
64
|
+
if (cfg.module == null) throw new Error('rdbga 值缺少 module——先在项目目录下运行(yonbuilder.json / module.xml)')
|
|
65
|
+
const bind = String(cfg.serverBindAddress ?? '')
|
|
66
|
+
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(bind) !== true) {
|
|
67
|
+
throw new Error('rdbga 值缺少合法的 serverBindAddress(IPv4)——补项目 .vscode/settings.json 的 yds.serverBindAddress')
|
|
68
|
+
}
|
|
69
|
+
const port = Number(cfg.serverPort)
|
|
70
|
+
if (Number.isInteger(port) !== true || port < 1 || port > 65535) {
|
|
71
|
+
throw new Error('rdbga 值缺少合法的 serverPort——补项目 .vscode/settings.json 的 yds.serverPort')
|
|
72
|
+
}
|
|
73
|
+
const context = String(cfg.context ?? '')
|
|
74
|
+
const raw = `${cfg.module},${context}@${bind}:${port}`
|
|
75
|
+
return Buffer.from(raw, 'utf8').toString('base64')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Full launch description: env, jvm args, args. */
|
|
79
|
+
export function launchSpec(cfg, { jvmArgs = [] } = {}) {
|
|
80
|
+
if (cfg.module == null) throw new Error('未识别模块名')
|
|
81
|
+
const logsDir = join(cfg.ymsHome, 'logs')
|
|
82
|
+
const env = {
|
|
83
|
+
...process.env,
|
|
84
|
+
mw_profiles_active: cfg.env,
|
|
85
|
+
YDS_STATE: 'debugging',
|
|
86
|
+
access_key: cfg.accessKey ?? '',
|
|
87
|
+
access_secret: cfg.accessSecret ?? '',
|
|
88
|
+
'access.key': cfg.accessKey ?? '',
|
|
89
|
+
'access.secret': cfg.accessSecret ?? '',
|
|
90
|
+
enable_cloud_debug: 'true',
|
|
91
|
+
YMS_ENABLE_MODULE: cfg.module,
|
|
92
|
+
yms_enable_module: cfg.module,
|
|
93
|
+
}
|
|
94
|
+
const jvm = [
|
|
95
|
+
`-DYMS_WORK_PATH=${join(cfg.ymsHome, 'workarea')}`,
|
|
96
|
+
'-DYMS-DEV=true',
|
|
97
|
+
'-DYMS-DEV-MODE=debug',
|
|
98
|
+
`-Dyms.log.path=${logsDir}`,
|
|
99
|
+
`-Dyms.dev.libProjectsFile=${join(cfg.ymsHome, '.libraryProjects.list')}`,
|
|
100
|
+
`-Dapp.metainfo.preferred-networks=${cfg.serverBindAddress}`,
|
|
101
|
+
'-Dapp.metainfo.ping_connect_timeout=200',
|
|
102
|
+
`-Ddomain.url=${cfg.workbenchUrl}`,
|
|
103
|
+
`-Dserver.port=${cfg.ymsPort}`,
|
|
104
|
+
`-Drdbga=${rdbgaValue(cfg)}`,
|
|
105
|
+
// start_mode=static: server discovery reads config/ip_node_mapping.json
|
|
106
|
+
// instead of eureka (unreachable from a dev machine) — from YonCode's
|
|
107
|
+
// generated .vscode/launch.json. Makes outbound RPC fall back to the
|
|
108
|
+
// domain.url cloud-debug rewrite for unreachable pod instances.
|
|
109
|
+
'-Dapp.metainfo.start_mode=static',
|
|
110
|
+
'-Dyms_fetch_config_version=2.0.0',
|
|
111
|
+
'-Dyms.bootstrap.enableNewConfig=true',
|
|
112
|
+
...ADD_EXPORTS.map((v) => `--add-exports=${v}`),
|
|
113
|
+
...ADD_OPENS.map((v) => `--add-opens=${v}`),
|
|
114
|
+
...jvmArgs.filter((v) => typeof v === 'string' && v.trim() !== '').map((v) => v.trim()),
|
|
115
|
+
]
|
|
116
|
+
return { java: cfg.java, env, jvm, main: 'iuap.yms.YMSLauncher', cp: join(cfg.ymsHome, 'yms.jar'), logsDir }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Ensure .javaExecutable / .libraryProjects.list exist so the home is self-contained. */
|
|
120
|
+
export function ensureHomeMarkers(cfg) {
|
|
121
|
+
if (cfg.java == null) throw new Error('未找到 JDK')
|
|
122
|
+
const exe = join(cfg.ymsHome, '.javaExecutable')
|
|
123
|
+
if (!existsSync(exe)) writeFileSync(exe, cfg.java + '\n', 'utf8')
|
|
124
|
+
const libs = join(cfg.ymsHome, '.libraryProjects.list')
|
|
125
|
+
if (!existsSync(libs)) writeFileSync(libs, '\n', 'utf8')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Detached start: spawn java setsid, stdout/stderr -> yms-console.log,
|
|
130
|
+
* pidfile in .dsh-ris-yds/yms.pid. Returns { started, pid } or
|
|
131
|
+
* { alreadyRunning }.
|
|
132
|
+
*/
|
|
133
|
+
/** Remember the port the running instance listens on (read back by config). */
|
|
134
|
+
function rememberPort(cfg) {
|
|
135
|
+
try {
|
|
136
|
+
mkdirSync(cfg.stateDir, { recursive: true })
|
|
137
|
+
writeFileSync(join(cfg.stateDir, 'yms-port'), `${cfg.ymsPort}\n`, { encoding: 'utf8' })
|
|
138
|
+
} catch { /* the port file is a convenience, never a requirement */ }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function startDetached(cfg, { jvmArgs = [] } = {}) {
|
|
142
|
+
const pidFile = ymsPidFile(cfg)
|
|
143
|
+
const running = await readRunning(cfg)
|
|
144
|
+
if (running !== null) {
|
|
145
|
+
if (running.state === 'unknown') {
|
|
146
|
+
return { started: false, alreadyRunning: false, unknown: true, pid: running.pid, reason: running.reason }
|
|
147
|
+
}
|
|
148
|
+
return { started: false, alreadyRunning: true, pid: running.pid }
|
|
149
|
+
}
|
|
150
|
+
if (!ymsHomeIsBuilt(cfg)) throw new Error('yms-home 未构建(先执行 yds build)')
|
|
151
|
+
if (cfg.java == null) throw new Error('未找到 JDK——用 .env 的 java.path 或 JAVA_HOME 指定')
|
|
152
|
+
if (cfg.module == null) throw new Error('未识别模块名——检查项目 yonbuilder.json / module.xml')
|
|
153
|
+
const spec = launchSpec(cfg, { jvmArgs })
|
|
154
|
+
ensureHomeMarkers(cfg)
|
|
155
|
+
rememberPort(cfg)
|
|
156
|
+
mkdirSync(cfg.stateDir, { recursive: true })
|
|
157
|
+
const log = join(cfg.stateDir, 'yms-console.log')
|
|
158
|
+
// crude rotation: keep the last full log, never let it grow unbounded
|
|
159
|
+
try {
|
|
160
|
+
const st = statSync(log)
|
|
161
|
+
if (st.size > 16 * 1024 * 1024) renameSync(log, `${log}.old`)
|
|
162
|
+
} catch { /* absent */ }
|
|
163
|
+
const out = openSync(log, 'a')
|
|
164
|
+
const err = openSync(log, 'a')
|
|
165
|
+
const child = spawn(spec.java, [...spec.jvm, '-cp', spec.cp, spec.main], {
|
|
166
|
+
cwd: cfg.ymsHome,
|
|
167
|
+
env: spec.env,
|
|
168
|
+
detached: true,
|
|
169
|
+
stdio: ['ignore', out, err],
|
|
170
|
+
windowsHide: true,
|
|
171
|
+
})
|
|
172
|
+
child.unref()
|
|
173
|
+
// parent copies of the log fds are dup'd into the child; close ours
|
|
174
|
+
try { closeSync(out) } catch { /* ignore */ }
|
|
175
|
+
try { closeSync(err) } catch { /* ignore */ }
|
|
176
|
+
writeFileSync(pidFile, String(child.pid), 'utf8')
|
|
177
|
+
// wait for the Liberty port to accept (~35s typical; hard budget 120s)
|
|
178
|
+
let up = false
|
|
179
|
+
for (let i = 0; i < 120 && !up; i += 1) {
|
|
180
|
+
// eslint-disable-next-line no-await-in-loop
|
|
181
|
+
up = await portOpen('127.0.0.1', cfg.ymsPort, 500)
|
|
182
|
+
if (!up) {
|
|
183
|
+
try { process.kill(child.pid, 0) } catch { throw new Error('YMS 进程已退出,查看 .dsh-ris-yds/yms-console.log') }
|
|
184
|
+
// eslint-disable-next-line no-await-in-loop
|
|
185
|
+
await new Promise((r) => setTimeout(r, 500))
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (!up) {
|
|
189
|
+
// TERM → grace → KILL (Windows: taskkill /T /F) so a half-started java tree cannot
|
|
190
|
+
// survive the failure and keep the port (review D-P2-5); the pidfile only goes
|
|
191
|
+
// away once the process is actually gone.
|
|
192
|
+
try { await stopPid(child.pid, { graceMs: 5000, group: true }) } catch { /* best effort */ }
|
|
193
|
+
try { unlinkSync(pidFile) } catch { /* ignore */ }
|
|
194
|
+
throw new Error(`YMS 端口 ${cfg.ymsPort} 在 120 秒内未就绪——查看 ${log}`)
|
|
195
|
+
}
|
|
196
|
+
return { started: true, pid: child.pid, port: cfg.ymsPort, consoleLog: log }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Foreground start: java as our child, signals forwarded (for run-with-log wrappers). */
|
|
200
|
+
export function startForeground(cfg, { jvmArgs = [], onStdout, signal } = {}) {
|
|
201
|
+
if (!ymsHomeIsBuilt(cfg)) throw new Error('yms-home 未构建(先执行 yds build)')
|
|
202
|
+
if (cfg.java == null) throw new Error('未找到 JDK——用 .env 的 java.path 或 JAVA_HOME 指定')
|
|
203
|
+
if (cfg.module == null) throw new Error('未识别模块名——检查项目 yonbuilder.json / module.xml')
|
|
204
|
+
const spec = launchSpec(cfg, { jvmArgs })
|
|
205
|
+
ensureHomeMarkers(cfg)
|
|
206
|
+
const child = spawn(spec.java, [...spec.jvm, '-cp', spec.cp, spec.main], {
|
|
207
|
+
cwd: cfg.ymsHome,
|
|
208
|
+
env: spec.env,
|
|
209
|
+
stdio: ['inherit', 'inherit', 'inherit'],
|
|
210
|
+
signal,
|
|
211
|
+
windowsHide: true,
|
|
212
|
+
})
|
|
213
|
+
const fwd = (sig) => {
|
|
214
|
+
try { child.kill(sig) } catch { /* ignore */ }
|
|
215
|
+
}
|
|
216
|
+
process.on('SIGTERM', () => fwd('SIGTERM'))
|
|
217
|
+
process.on('SIGINT', () => fwd('SIGINT'))
|
|
218
|
+
// record the java pid so `yds status` / `yds stop` are not blind to
|
|
219
|
+
// IDE-started (foreground) instances; unlink when the child goes away.
|
|
220
|
+
try {
|
|
221
|
+
mkdirSync(cfg.stateDir, { recursive: true })
|
|
222
|
+
writeFileSync(ymsPidFile(cfg), String(child.pid), 'utf8')
|
|
223
|
+
child.once('close', () => { try { unlinkSync(ymsPidFile(cfg)) } catch { /* ignore */ } })
|
|
224
|
+
} catch { /* best effort */ }
|
|
225
|
+
return child
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const YMS_MARKERS = ['yms.jar', 'YMSLauncher']
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Identity of a pidfile owner: `ours` / `foreign` / `dead` / `unknown`. The four-way
|
|
232
|
+
* answer matters: a probe that could not read the command line must never be treated
|
|
233
|
+
* as "not our process" (that deletes the pidfile and starts a second YMS — review
|
|
234
|
+
* D-P1-6).
|
|
235
|
+
* @returns {{state: 'ours'|'foreign'|'dead'|'unknown', commandLine: string}}
|
|
236
|
+
*/
|
|
237
|
+
export function probeYmsPid(pid, options = {}) {
|
|
238
|
+
return pidIdentity(pid, YMS_MARKERS, options)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Read the pidfile; verify the process is alive AND ours.
|
|
243
|
+
* @returns {null | {pid: number, state?: 'unknown', reason?: string}}
|
|
244
|
+
* `null` when absent/stale/foreign (the stale pidfile is unlinked). An `unknown`
|
|
245
|
+
* entry keeps the pidfile and must be treated as "do not start a second one, do not
|
|
246
|
+
* kill it blindly".
|
|
247
|
+
*/
|
|
248
|
+
export async function readRunning(cfg, options = {}) {
|
|
249
|
+
const pidFile = ymsPidFile(cfg)
|
|
250
|
+
if (!existsSync(pidFile)) return null
|
|
251
|
+
const pid = Number(readFileSync(pidFile, 'utf8').trim())
|
|
252
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
253
|
+
try { unlinkSync(pidFile) } catch { /* ignore */ }
|
|
254
|
+
return null
|
|
255
|
+
}
|
|
256
|
+
const identity = probeYmsPid(pid, options)
|
|
257
|
+
if (identity.state === 'ours') return { pid }
|
|
258
|
+
if (identity.state === 'dead' || identity.state === 'foreign') {
|
|
259
|
+
// Gone, or the pid was reused by an unrelated process after a crash/reboot.
|
|
260
|
+
try { unlinkSync(pidFile) } catch { /* ignore */ }
|
|
261
|
+
return null
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
pid,
|
|
265
|
+
state: 'unknown',
|
|
266
|
+
reason: '无法确认 pidfile 进程身份(命令行为空或探测失败),已保留 pidfile 未动',
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Stop the detached YMS: TERM, grace (YMS is slow), KILL; Windows uses taskkill /T /F. */
|
|
271
|
+
export async function stopYms(cfg, { graceMs = 30000, ...options } = {}) {
|
|
272
|
+
const running = await readRunning(cfg, options) // identity-verified: never kills a reused pid
|
|
273
|
+
if (running === null) return { stopped: false, reason: '未在运行' }
|
|
274
|
+
if (running.state === 'unknown') {
|
|
275
|
+
return {
|
|
276
|
+
stopped: false,
|
|
277
|
+
pid: running.pid,
|
|
278
|
+
reason: `${running.reason};为避免误杀,未发送停止信号(请手动确认该 pid)`,
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Windows has no graceful signal path: taskkill the tree; POSIX TERM→grace→KILL.
|
|
282
|
+
const killed = await stopPid(running.pid, { graceMs, ...options })
|
|
283
|
+
if (killed.ok !== true) {
|
|
284
|
+
// Keep the pidfile so `yds status` still shows the process (review D-P1-7).
|
|
285
|
+
return { stopped: false, pid: running.pid, reason: `停止失败:${killed.error ?? '未知原因'}(已保留 pidfile)` }
|
|
286
|
+
}
|
|
287
|
+
try { unlinkSync(ymsPidFile(cfg)) } catch { /* ignore */ }
|
|
288
|
+
return { stopped: true, pid: running.pid }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Status row: process + port + forwarder + config state. */
|
|
292
|
+
export async function status(cfg) {
|
|
293
|
+
const running = await readRunning(cfg) // identity-checked
|
|
294
|
+
const port = await portOpen('127.0.0.1', cfg.ymsPort, 800)
|
|
295
|
+
let ready = null
|
|
296
|
+
if (port === true) {
|
|
297
|
+
ready = await httpGet(`http://127.0.0.1:${cfg.ymsPort}/_yms/ready`, 3000)
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
process: running,
|
|
301
|
+
processUnknown: running !== null && running.state === 'unknown',
|
|
302
|
+
port,
|
|
303
|
+
ready: ready !== null ? ready.status : null,
|
|
304
|
+
ymsHomeBuilt: ymsHomeIsBuilt(cfg),
|
|
305
|
+
configPresent: existsSync(join(cfg.ymsHome, 'config', 'yms_middleware.json')),
|
|
306
|
+
socksForwarder: cfg.socks === null ? null : await portOpen('127.0.0.1', cfg.dbForwardPort, 300),
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function httpGet(url, timeout) {
|
|
311
|
+
return new Promise((resolve_) => {
|
|
312
|
+
const done = (value) => resolve_(value)
|
|
313
|
+
const r = http.get(url, (res) => { res.resume(); done({ status: res.statusCode }) })
|
|
314
|
+
r.setTimeout(timeout, () => { r.destroy(); done(null) })
|
|
315
|
+
r.once('error', () => done(null))
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Tail helper for yds logs — reads only the last 256KB (log can be huge). */
|
|
320
|
+
export function tailFile(path, lines = 80) {
|
|
321
|
+
if (!existsSync(path)) return null
|
|
322
|
+
const st = statSync(path)
|
|
323
|
+
const readSize = Math.min(st.size, 256 * 1024)
|
|
324
|
+
const fh = openSync(path, 'r')
|
|
325
|
+
try {
|
|
326
|
+
const buf = Buffer.alloc(readSize)
|
|
327
|
+
readSync(fh, buf, 0, readSize, st.size - readSize)
|
|
328
|
+
const text = buf.toString('utf8')
|
|
329
|
+
const all = text.split('\n')
|
|
330
|
+
// first line may be a partial line from the mid-file cut — drop it
|
|
331
|
+
if (readSize < st.size && all.length > 1) all.shift()
|
|
332
|
+
return all.slice(-lines).join('\n')
|
|
333
|
+
} finally {
|
|
334
|
+
closeSync(fh)
|
|
335
|
+
}
|
|
336
|
+
}
|