Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tough-lilies-bet.md
Original file line number Diff line number Diff line change
@@ -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
6 changes: 5 additions & 1 deletion packages/integrations/node/src/serve-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions packages/integrations/node/test/static-headers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 5 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading