fix(fastify): listen on a file descriptor (#18226)

This commit is contained in:
Roman Khegay authored and GitHub committed 2026-10-22 08:36:19 +02:00
1 parent 237cab29d9
commit fe2faa0ab8
7 files changed
+114 -8

No files matched your search

@@ -0,0 +1,52 @@
import { ChildProcess, spawn } from 'child_process';
import { AddressInfo, createServer } from 'net';
import { join } from 'path';
const waitUntilListening = (child: ChildProcess) =>
new Promise<void>((resolve, reject) => {
let stderr = '';
child.stderr!.on('data', chunk => (stderr += chunk));
child.stdout!.on('data', chunk => {
if (String(chunk).includes('listening')) {
resolve();
}
});
child.on('exit', code =>
reject(new Error(`The app exited with code ${code}: ${stderr}`)),
);
});
// Node cannot listen on a file descriptor on Windows.
describe.skipIf(process.platform === 'win32')(
'Listen on a file descriptor (Fastify Application)',
() => {
let child: ChildProcess;
afterEach(() => {
child?.kill();
});
it('should listen on the socket the process inherits', async () => {
// A process cannot listen on a descriptor that its own event loop
// already watches (EEXIST), so the app runs in a child process that
// inherits the socket as fd 3, as with systemd socket activation.
const server = createServer();
await new Promise<void>(resolve =>
server.listen(0, '127.0.0.1', resolve),
);
const { port } = server.address() as AddressInfo;
child = spawn(
process.execPath,
[join(import.meta.dirname, '../src/fastify-fd-main.js')],
{ stdio: ['ignore', 'pipe', 'pipe', (server as any)._handle.fd] },
);
server.close();
await waitUntilListening(child);
const response = await fetch(`http://127.0.0.1:${port}`);
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe('Hello World!');
});
},
);
@@ -0,0 +1,28 @@
import { Controller, Get, Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter } from '@nestjs/platform-fastify';
// Plain JS with decorators applied by hand: under `--import jiti/register`,
// Fastify fails to load on some Node versions (22.16, for one).
class AppController {
getHello() {
return 'Hello World!';
}
}
Get()(
AppController.prototype,
'getHello',
Object.getOwnPropertyDescriptor(AppController.prototype, 'getHello'),
);
Controller()(AppController);
class AppModule {}
Module({ controllers: [AppController] })(AppModule);
const app = await NestFactory.create(AppModule, new FastifyAdapter(), {
logger: false,
});
// The socket is inherited as fd 3, as systemd passes it on socket activation.
await app.listen({ fd: 3 });
console.log('listening');
@@ -18,7 +18,6 @@ import {
FastifyBodyParser,
FastifyError,
FastifyInstance,
FastifyListenOptions,
FastifyPluginAsync,
FastifyPluginCallback,
FastifyRegister,
@@ -77,7 +76,10 @@ import {
FastifyStaticOptions,
FastifyViewOptions,
} from '../interfaces/external/index.js';
import { NestFastifyBodyParserOptions } from '../interfaces/index.js';
import {
NestFastifyBodyParserOptions,
NestFastifyListenOptions,
} from '../interfaces/index.js';
const { safeDecodeURI } = urlSanitizer;
const MISSING_MULTIPART_PACKAGE_MESSAGE =
@@ -567,7 +569,7 @@ export class FastifyAdapter<
callback?: () => void,
): void;
public listen(
listenOptions: string | number | FastifyListenOptions,
listenOptions: string | number | NestFastifyListenOptions,
...args: any[]
): void {
const isFirstArgTypeofFunction = typeof args[0] === 'function';
@@ -584,9 +586,10 @@ export class FastifyAdapter<
typeof listenOptions === 'object' &&
(listenOptions.host !== undefined ||
listenOptions.port !== undefined ||
listenOptions.path !== undefined)
listenOptions.path !== undefined ||
listenOptions.fd !== undefined)
) {
// First parameter is an object with a path, port and/or host attributes
// First parameter is an object with a path, port, fd and/or host attributes
options = listenOptions;
} else {
options = {
@@ -1,2 +1,3 @@
export * from './nest-fastify-application.interface.js';
export * from './nest-fastify-body-parser-options.interface.js';
export * from './nest-fastify-listen-options.interface.js';
@@ -3,7 +3,6 @@ import type { HttpServer, INestApplication } from '@nestjs/common';
import {
FastifyBodyParser,
FastifyInstance,
FastifyListenOptions,
FastifyPluginAsync,
FastifyPluginCallback,
FastifyPluginOptions,
@@ -20,6 +19,7 @@ import {
} from 'light-my-request';
import { FastifyStaticOptions, FastifyViewOptions } from './external/index.js';
import { NestFastifyBodyParserOptions } from './nest-fastify-body-parser-options.interface.js';
import { NestFastifyListenOptions } from './nest-fastify-listen-options.interface.js';
/**
* @publicApi
@@ -103,10 +103,10 @@ export interface NestFastifyApplication<
* @returns A Promise that, when resolved, is a reference to the underlying HttpServer.
*/
listen(
opts: FastifyListenOptions,
opts: NestFastifyListenOptions,
callback?: (err: Error | null, address: string) => void,
): Promise<TServer>;
listen(opts?: FastifyListenOptions): Promise<TServer>;
listen(opts?: NestFastifyListenOptions): Promise<TServer>;
listen(
callback?: (err: Error | null, address: string) => void,
): Promise<TServer>;
@@ -0,0 +1,13 @@
import type { FastifyListenOptions } from 'fastify';
/**
* @publicApi
*/
export interface NestFastifyListenOptions extends FastifyListenOptions {
/**
* A file descriptor of a socket that is already bound, such as the one
* systemd passes on socket activation. Not supported on Windows.
* @see [server.listen(handle)](https://nodejs.org/api/net.html#serverlistenhandle-backlog-callback)
*/
fd?: number;
}
@@ -1615,6 +1615,15 @@ describe('FastifyAdapter', () => {
expect(listen).toHaveBeenCalledWith({ port: 3000 }, undefined);
});
it('should keep an options object with a file descriptor as it is', () => {
const listen = spyOnListen();
const callback = vi.fn();
fastifyAdapter.listen({ fd: 3 }, callback);
expect(listen).toHaveBeenCalledWith({ fd: 3 }, callback);
});
it('should keep a number as a port', () => {
const listen = spyOnListen();