- 事件头只认 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 遗留代码
104 lines
3.6 KiB
TypeScript
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),
|
|
})
|
|
}
|
|
}))
|
|
}
|