From 0f7f0866ef367f8023cc5a29dcf59eefc1ba12d0 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Thu, 10 Sep 2026 10:34:14 +0800 Subject: [PATCH] fix: release response bodies in external link checks --- package.json | 3 +- scripts/check-external-links.mjs | 3 ++ scripts/check-external-links.test.mjs | 46 +++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 scripts/check-external-links.test.mjs diff --git a/package.json b/package.json index e202960..de071c6 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "check:content": "node scripts/check-content.mjs", "check:site": "node scripts/check-site.mjs", "check:external": "node scripts/check-external-links.mjs", - "check": "npm run check:content && npm run build && npm run check:site" + "test": "node --test scripts/*.test.mjs", + "check": "npm test && npm run check:content && npm run build && npm run check:site" }, "dependencies": { "astro": "^7.2.8" diff --git a/scripts/check-external-links.mjs b/scripts/check-external-links.mjs index e415760..92f5888 100644 --- a/scripts/check-external-links.mjs +++ b/scripts/check-external-links.mjs @@ -46,6 +46,9 @@ async function check(url) { headers: { 'user-agent': 'SagaSmithAI-site-link-check/1.0', range: 'bytes=0-0' }, }); } + // Only the status is needed. A server may ignore Range and stream an + // unlimited GET body, keeping connections and the checker alive. + await response.body?.cancel(); if (response.status === 404 || response.status === 410) failures.push(`${response.status} ${url}`); else if (!response.ok && response.status !== 403 && response.status !== 429) failures.push(`${response.status} ${url}`); else if (!response.ok) warnings.push(`${response.status} ${url}`); diff --git a/scripts/check-external-links.test.mjs b/scripts/check-external-links.test.mjs new file mode 100644 index 0000000..c8fd7a7 --- /dev/null +++ b/scripts/check-external-links.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +test('GET fallback cancels a body when the server ignores Range', { timeout: 10_000 }, async () => { + const root = await mkdtemp(path.join(tmpdir(), 'sagasmith-links-')); + const server = createServer((request, response) => { + if (request.method === 'HEAD') { + response.writeHead(405).end(); + return; + } + response.writeHead(200, { 'content-type': 'text/plain' }); + response.write('body that continues until cancelled'); + const interval = setInterval(() => response.write('.'), 100); + response.on('close', () => clearInterval(interval)); + }); + let child; + try { + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + await mkdir(path.join(root, 'dist')); + await writeFile(path.join(root, 'dist/index.html'), + `Resource`); + const script = fileURLToPath(new URL('./check-external-links.mjs', import.meta.url)); + child = spawn(process.execPath, [script], { cwd: root, stdio: 'pipe' }); + let output = ''; + child.stdout.on('data', (chunk) => { output += chunk; }); + child.stderr.on('data', (chunk) => { output += chunk; }); + const timeout = setTimeout(() => child.kill(), 5_000); + const [code] = await once(child, 'close'); + clearTimeout(timeout); + assert.equal(code, 0, output); + assert.match(output, /External-link checks passed/); + } finally { + if (child && child.exitCode === null) child.kill(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + await rm(root, { recursive: true, force: true }); + } +});