From 8b211a56c86f0e41782e6d7fa4b4d13dec895e80 Mon Sep 17 00:00:00 2001 From: "Houston (Bot)" <108291165+astrobot-houston@users.noreply.github.com> Date: Mon, 10 Aug 2026 05:20:41 -0700 Subject: [PATCH] fix(node): clean up abort controller socket listener in static header matching\n\nWhen `staticHeaders` is enabled with CSP, the static file handler creates\na Web Request via `createRequestFromNodeRequest` solely for route matching.\nThis internally wires an AbortController that adds a `close` listener to\nthe socket. Since the Request is discarded after matching, the listener\nwas never cleaned up, causing them to accumulate on keep-alive connections\nand triggering Node's MaxListenersExceededWarning after ~11 requests.\n\nCall `getAbortControllerCleanup(req)?.()` after matching to remove the\nsocket listener immediately, matching the pattern already used in\nserve-app.ts (PR #15735).\n\nCloses #17657" (#17658) --- .changeset/tough-lilies-bet.md | 5 ++ .../integrations/node/src/serve-static.ts | 6 ++- .../node/test/static-headers.test.ts | 53 +++++++++++++++++++ pnpm-lock.yaml | 7 ++- 4 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 .changeset/tough-lilies-bet.md diff --git a/.changeset/tough-lilies-bet.md b/.changeset/tough-lilies-bet.md new file mode 100644 index 000000000000..74a3c4209391 --- /dev/null +++ b/.changeset/tough-lilies-bet.md @@ -0,0 +1,5 @@ +--- +'@astrojs/node': patch +--- + +Fixes an EventEmitter memory leak when serving static pages over keep-alive connections with `staticHeaders` enabled and CSP (`security.csp`) active diff --git a/packages/integrations/node/src/serve-static.ts b/packages/integrations/node/src/serve-static.ts index 09029bdc15bc..555201cf2cc0 100644 --- a/packages/integrations/node/src/serve-static.ts +++ b/packages/integrations/node/src/serve-static.ts @@ -6,7 +6,7 @@ import type { BaseApp } from 'astro/app'; import send from 'send'; import { resolveClientDir } from './shared.js'; import type { NodeAppHeadersJson, Options } from './types.js'; -import { createRequestFromNodeRequest } from 'astro/app/node'; +import { createRequestFromNodeRequest, getAbortControllerCleanup } from 'astro/app/node'; /** * Resolves a URL path to a filesystem path within the client directory, @@ -72,6 +72,10 @@ export function createStaticHandler( port: options.port, }); const routeData = app.match(request, true); + // The Request is only used for route matching above. Clean up the + // socket `close` listener that wireAbortController added, otherwise + // keep-alive connections accumulate listeners on every request. + getAbortControllerCleanup(req)?.(); if (routeData && routeData.prerender) { // Headers are stored keyed by base-less route paths (e.g. "/one"), so we // must strip config.base from the incoming URL before matching, just as diff --git a/packages/integrations/node/test/static-headers.test.ts b/packages/integrations/node/test/static-headers.test.ts index 68bfb82b089b..1d3eb6bb2c2b 100644 --- a/packages/integrations/node/test/static-headers.test.ts +++ b/packages/integrations/node/test/static-headers.test.ts @@ -75,6 +75,59 @@ describe('Static headers', () => { }); }); +describe('Static headers listener cleanup', () => { + let fixture: Fixture; + let server: AdapterServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/static-headers/', + outDir: './dist/listener-cleanup', + output: 'server', + adapter: nodejs({ mode: 'standalone', staticHeaders: true }), + }); + await fixture.build(); + const { startServer } = await fixture.loadAdapterEntryModule(); + process.env.PORT = '4324'; + const res = startServer(); + server = res.server; + await waitServerListen(server.server); + }); + + after(async () => { + await server.stop(); + }); + + it('does not leak socket close listeners on keep-alive connections', async () => { + const http = await import('node:http'); + const agent = new http.Agent({ keepAlive: true }); + + let warningEmitted = false; + const onWarning = (warning: Error) => { + if (warning.name === 'MaxListenersExceededWarning') { + warningEmitted = true; + } + }; + process.on('warning', onWarning); + + try { + for (let i = 0; i < 30; i++) { + const res = await fetch(`http://${server.host}:${server.port}/`, { + // @ts-expect-error Node fetch doesn't type `agent` + agent, + headers: { Connection: 'keep-alive' }, + }); + await res.text(); + } + } finally { + process.off('warning', onWarning); + agent.destroy(); + } + + assert.equal(warningEmitted, false, 'MaxListenersExceededWarning should not be emitted'); + }); +}); + describe('Static headers with non-root base', () => { let fixture: Fixture; let server: AdapterServer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e95ef333859a..a0a01b7e53a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7119,10 +7119,13 @@ importers: specifier: ^4.22.0 version: 4.22.3 - triage/gh-17624: + triage/gh-17657: dependencies: + '@astrojs/node': + specifier: workspace:* + version: link:../../packages/integrations/node astro: - specifier: ^7.2.0 + specifier: workspace:* version: link:../../packages/astro packages: