117 lines
3.4 KiB
TypeScript
117 lines
3.4 KiB
TypeScript
import { execFile, spawn } from 'node:child_process'
|
|
import { promises as fsp, statSync } from 'node:fs'
|
|
import * as path from 'node:path'
|
|
|
|
/** Gitea 工单附件上限。压缩后的 tgz 超过这个值,服务器会拒收。 */
|
|
export const HANDOFF_ATTACHMENT_MAX_BYTES = 100 * 1024 * 1024
|
|
|
|
export function formatByteSize(bytes: number): string {
|
|
const mb = bytes / (1024 * 1024)
|
|
if (mb >= 10)
|
|
return `${Math.round(mb)}MB`
|
|
if (mb >= 1)
|
|
return `${mb.toFixed(1)}MB`
|
|
const kb = bytes / 1024
|
|
if (kb >= 10)
|
|
return `${Math.round(kb)}KB`
|
|
if (kb >= 1)
|
|
return `${kb.toFixed(1)}KB`
|
|
return `${bytes}B`
|
|
}
|
|
|
|
export class HandoffArchiveTooLargeError extends Error {
|
|
readonly packedBytes: number
|
|
readonly limitBytes: number
|
|
|
|
constructor(packedBytes: number, limitBytes: number) {
|
|
super(`压缩后超过 Gitea 附件上限 ${formatByteSize(limitBytes)}`)
|
|
this.name = 'HandoffArchiveTooLargeError'
|
|
this.packedBytes = packedBytes
|
|
this.limitBytes = limitBytes
|
|
}
|
|
}
|
|
|
|
function tarUnpack(args: string[]): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
execFile('tar', args, { timeout: 120_000 }, (err, _stdout, stderr) => {
|
|
if (err) {
|
|
reject(new Error(`tar ${args[0]} 失败: ${(stderr ?? '').toString().trim() || err.message}`))
|
|
return
|
|
}
|
|
resolve()
|
|
})
|
|
})
|
|
}
|
|
|
|
export async function packHandoff(
|
|
stagingDir: string,
|
|
outFile: string,
|
|
opts?: { maxBytes?: number, onProgress?: (packedBytes: number) => void },
|
|
): Promise<void> {
|
|
const maxBytes = opts?.maxBytes ?? HANDOFF_ATTACHMENT_MAX_BYTES
|
|
await fsp.mkdir(path.dirname(outFile), { recursive: true })
|
|
await new Promise<void>((resolve, reject) => {
|
|
const child = spawn('tar', ['-czf', outFile, '-C', stagingDir, '.'], { stdio: ['ignore', 'ignore', 'pipe'] })
|
|
const decode = new TextDecoder()
|
|
let stderr = ''
|
|
let settled = false
|
|
let poll: ReturnType<typeof setInterval> | undefined
|
|
child.stderr?.on('data', (chunk: Uint8Array) => {
|
|
stderr += decode.decode(chunk, { stream: true })
|
|
})
|
|
const finish = (err?: Error): void => {
|
|
if (settled)
|
|
return
|
|
settled = true
|
|
if (poll)
|
|
clearInterval(poll)
|
|
if (err)
|
|
reject(err)
|
|
else
|
|
resolve()
|
|
}
|
|
const checkSize = (): void => {
|
|
let size = 0
|
|
try {
|
|
size = statSync(outFile).size
|
|
}
|
|
catch {
|
|
return
|
|
}
|
|
opts?.onProgress?.(size)
|
|
// 产出一超过上限就杀掉。等 tar 自己打完再失败,大会话会卡很久。
|
|
if (size > maxBytes) {
|
|
child.kill('SIGKILL')
|
|
finish(new HandoffArchiveTooLargeError(size, maxBytes))
|
|
}
|
|
}
|
|
poll = setInterval(checkSize, 200)
|
|
child.on('error', err => finish(err))
|
|
child.on('close', (code) => {
|
|
if (settled)
|
|
return
|
|
let size = 0
|
|
try {
|
|
size = statSync(outFile).size
|
|
}
|
|
catch {
|
|
// 包还没写出来
|
|
}
|
|
opts?.onProgress?.(size)
|
|
if (size > maxBytes) {
|
|
finish(new HandoffArchiveTooLargeError(size, maxBytes))
|
|
return
|
|
}
|
|
if (code === 0)
|
|
finish()
|
|
else
|
|
finish(new Error(`tar -czf 失败: ${stderr.trim() || `exit ${code}`}`))
|
|
})
|
|
})
|
|
}
|
|
|
|
export async function unpackHandoff(archive: string, dstDir: string): Promise<void> {
|
|
await fsp.mkdir(dstDir, { recursive: true })
|
|
await tarUnpack(['-xzf', archive, '-C', dstDir])
|
|
}
|