Skip to content
Draft
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
19 changes: 9 additions & 10 deletions cli/src/commands/docs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Command, flags } from "@oclif/command";
import express from "express";
import { listen } from "../../../lib/src/express-listen";
import path from "path";
import { generateOpenAPI3 } from "../../../lib/src/generators/openapi3/openapi3";
import { parse } from "../../../lib/src/parser";
Expand Down Expand Up @@ -49,15 +50,13 @@ export default class Docs extends Command {
res.send(openApiObj);
});

const start = async (): Promise<void> => {
try {
this.log(`Documentation server started on port ${port}`);
this.log(`Open http://localhost:${port} to view documentation`);
await server.listen(port);
} catch (err) {
this.error(err as Error, { exit: 1 });
}
};
start();
try {
await listen(server, port);
} catch (err) {
this.error(err as Error, { exit: 1 });
}

this.log(`Documentation server started on port ${port}`);
this.log(`Open http://localhost:${port} to view documentation`);
}
}
48 changes: 48 additions & 0 deletions lib/src/express-listen.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import express from "express";
import * as net from "net";
import { listen } from "./express-listen";

describe("listen", () => {
it("resolves once the server is accepting requests", async () => {
// The polarity's other half: a callback written to always reject type-checks
// just as well and would make every server exit on a clean start.
const server = await listen(express(), 0);
try {
expect(server.listening).toBe(true);
} finally {
await new Promise<void>(resolve => server.close(() => resolve()));
}
});

it("rejects when the port cannot be bound", async () => {
// A callback that ignores its argument type-checks and resolves here,
// reporting a server that is not accepting requests as started.
const occupier = net.createServer();
occupier.on("error", () => undefined);
const port = await new Promise<number>(resolve => {
occupier.listen(0, () =>
resolve((occupier.address() as net.AddressInfo).port)
);
});

try {
await expect(listen(express(), port)).rejects.toMatchObject({
code: "EADDRINUSE"
});
} finally {
await new Promise<void>(resolve => occupier.close(() => resolve()));
}
});

it("leaves a post-bind error on the default path rather than swallowing it", async () => {
// express's own listener is spent once `listening` wins but stays attached,
// so without dropping it an error raised later reaches a no-op and vanishes.
const server = await listen(express(), 0);
try {
expect(server.listenerCount("error")).toBe(0);
expect(() => server.emit("error", new Error("later"))).toThrow("later");
} finally {
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
});
31 changes: 31 additions & 0 deletions lib/src/express-listen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Application } from "express";
import { Server } from "http";

/**
* Start `app` on `port`, resolving once it is accepting requests.
*
* express adds this callback as the server's `error` listener as well as
* handing it to node's `listening`, and wraps it so the first of the two wins.
* Two consequences, and neither is optional to handle:
*
* A port that cannot be bound arrives here as the argument rather than as an
* unhandled event. A callback that ignores it resolves, which reports a server
* that never bound as started.
*
* Once `listening` has won, that listener is spent but stays attached, so an
* error raised after the bind — a descriptor limit reached while accepting,
* say — is delivered to a no-op and vanishes. Dropping it puts a later error
* back on the default path, where it terminates the process instead.
*/
export function listen(app: Application, port: number): Promise<Server> {
return new Promise((resolve, reject) => {
const server = app.listen(port, (error?: Error) => {
if (error) {
reject(error);
return;
}
server.removeAllListeners("error");
resolve(server);
});
});
}
5 changes: 4 additions & 1 deletion lib/src/mock-server/server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import cors from "cors";
import express from "express";
import { listen } from "../express-listen";
import { Contract } from "../definitions";
import { TypeTable } from "../types";
import { Logger } from "../utilities/logger";
Expand Down Expand Up @@ -104,6 +105,8 @@ export function runMockServer(
});
return {
app,
defer: () => new Promise<void>(resolve => app.listen(port, resolve))
defer: async (): Promise<void> => {
await listen(app, port);
}
};
}
5 changes: 4 additions & 1 deletion lib/src/validation-server/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import express from "express";
import { listen } from "../express-listen";
import { Contract } from "../definitions";
import { InternalServerError } from "./spots/utils";
import {
Expand Down Expand Up @@ -62,7 +63,9 @@ export function runValidationServer(

return {
app,
defer: () => new Promise<void>(resolve => app.listen(port, resolve))
defer: async (): Promise<void> => {
await listen(app, port);
}
};
}

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"cors": "^2.8.5",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
"express": "^4.19.2",
"express": "^5.2.1",
"fs-extra": "^11.4.0",
"inquirer": "^8.1.1",
"js-yaml": "^5.2.3",
Expand All @@ -35,7 +35,7 @@
"@oclif/dev-cli": "^1.26.0",
"@stoplight/spectral": "^5.9.2",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/express": "^5.0.6",
"@types/fs-extra": "^11.0.4",
"@types/inquirer": "^8.1.2",
"@types/jest": "^30.0.0",
Expand Down
Loading
Loading