Files
superwork/vscode/src/webhook/relay.ts
T
cruldra ac3c882672 ✨ feat(vscode): webhook 改认 GitHub 风格头并支持 GitHub,事件按仓库分给对应窗口
- 事件头只认 X-GitHub-Event、签名只认 X-Hub-Signature-256: sha256=<hex>(Gitea v28 也发这两个头);ping 正常应答,form 编码返回 415 并提示改成 JSON
- 只有 pull_request 事件才当 PR 事件,PR 合并状态直接取 payload;评论人只认 login
- 每个事件带上 payload 里的仓库,不是本窗口 origin 的事件整条忽略,provider 按事件里的仓库打开
- 本机多个窗口时由占到端口的窗口把验过签的请求转给其余窗口,它关掉后其余窗口自动接手端口
- 删掉不再使用的 pendingHooks 遗留代码
2026-10-19 08:23:42 +11:00

104 lines
3.6 KiB
TypeScript

/**
* 本机开着好几个 VS Code 窗口时只有一个窗口占得到 webhook 端口,它把验过签的请求原样转给其余窗口,每个窗口再按事件里的仓库决定管不管。
*
* 没占到端口的窗口在 `127.0.0.1` 上另开一个随机端口,登记到 [PeerRegistry] 的目录里:一个端口一个文件,文件内容是该窗口扩展进程的 pid。
*/
import type { Buffer } from 'node:buffer'
import { promises as fsp } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { logger } from '../logging/logger'
/** 转发时带上的请求头:解析事件和校验签名只看这几个。 */
export const RELAYED_HEADERS = ['content-type', 'x-github-event', 'x-hub-signature-256'] as const
/** 各公网端口登记目录的上级目录,放在系统临时目录里,换了 VS Code profile 的窗口也找得到彼此。 */
export function defaultPeerRoot(): string {
return path.join(tmpdir(), 'superpowers-webhook')
}
/** 进程还在就算活着;没权限探测(`EPERM`)说明进程存在,只是属于别的用户。 */
function processAlive(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0)
return false
try {
process.kill(pid, 0)
return true
}
catch (err) {
return (err as NodeJS.ErrnoException).code === 'EPERM'
}
}
/** 没占到公网端口的窗口在这里登记自己的本地端口,占到端口的窗口从这里找转发对象。 */
export class PeerRegistry {
constructor(private readonly dir: string) {}
async add(port: number): Promise<void> {
await fsp.mkdir(this.dir, { recursive: true })
await fsp.writeFile(path.join(this.dir, String(port)), String(process.pid))
}
async remove(port: number): Promise<void> {
await fsp.rm(path.join(this.dir, String(port)), { force: true })
}
/** 还活着的窗口的本地端口;进程已经退出的登记顺手删掉,窗口崩溃时没来得及注销的就是这么清掉的。 */
async ports(): Promise<number[]> {
let names: string[]
try {
names = await fsp.readdir(this.dir)
}
catch {
return []
}
const out: number[] = []
for (const name of names) {
const port = Number(name)
if (!Number.isInteger(port) || port <= 0)
continue
const pid = Number(await fsp.readFile(path.join(this.dir, name), 'utf8').catch(() => ''))
if (processAlive(pid))
out.push(port)
else
await this.remove(port)
}
return out
}
}
export interface RelayedRequest {
/** 原请求的路径,如 `/webhook`。 */
url: string
headers: Record<string, string>
body: Buffer
}
/** 把请求转给每个登记过的窗口;连不上的登记删掉,其余失败只记日志,一个窗口出错不影响别的窗口。 */
export async function forwardToPeers(registry: PeerRegistry, request: RelayedRequest): Promise<void> {
const ports = await registry.ports()
await Promise.all(ports.map(async (port) => {
try {
await fetch(`http://127.0.0.1:${port}${request.url}`, {
method: 'POST',
headers: request.headers,
body: request.body,
signal: AbortSignal.timeout(5000),
})
}
catch (err) {
const cause = (err as { cause?: { code?: unknown } }).cause
if (cause?.code === 'ECONNREFUSED')
await registry.remove(port)
logger.add({
level: 'warn',
source: 'webhook',
message: `转发给本机其它窗口失败 127.0.0.1:${port}`,
details: err instanceof Error ? err.message : String(err),
})
}
}))
}