lukeautry / tsoa

Build OpenAPI-compliant REST APIs using TypeScript and Node
MIT License
3.48k stars 498 forks source link

Can't send stream to injected error responder when using '@Security()` decorator. (Really I'm just trying to create a plain-text error) #1614

Closed theScottyJam closed 4 months ago

theScottyJam commented 5 months ago

Sorting

Expected Behavior

I should be able to return a stream response via an injected error responder. (More generally, I just want to return arbitrary text in a response, but I understand that I can't do that unless I provide the text as a stream due to the fact that https://github.com/lukeautry/tsoa/issues/1394 was never resolved).

@Route("users")
export class UsersController extends Controller {
  @Get("get-one")
  @Security("api_key")
  public async getUser(
    @Res() forbiddenResponse: TsoaResponse<403, Readable, { 'Content-Type': 'text/plain' }>
  ): Promise<{ thisIsNeverReturned: true }> {
    return forbiddenResponse(403, Readable.from('This is a forbidden response'), { 'Content-Type': 'text/plain' });
  }
}

Current Behavior

Hitting that endpoint gives me the error:

node:events:495
      throw er; // Unhandled 'error' event
      ^

Error [ERR_STREAM_WRITE_AFTER_END]: write after end
    at new NodeError (node:internal/errors:405:5)
    at write_ (node:_http_outgoing:881:11)
    at ServerResponse.write (node:_http_outgoing:834:15)
    at Readable.ondata (node:internal/streams/readable:809:22)
    at Readable.emit (node:events:517:28)
    at Readable.read (node:internal/streams/readable:582:10)
    at flow (node:internal/streams/readable:1064:34)
    at resume_ (node:internal/streams/readable:1045:3)
    at process.processTicksAndRejections (node:internal/process/task_queues:82:21)
Emitted 'error' event on ServerResponse instance at:
    at emitErrorNt (node:_http_outgoing:853:9)
    at process.processTicksAndRejections (node:internal/process/task_queues:83:21) {
  code: 'ERR_STREAM_WRITE_AFTER_END'
}

The user performing the request gets a "204 No Content" response instead of the error.

Context (Environment)

Version of the library: 6.2.0 Version of NodeJS: V18.15.0

Detailed Description

If you comment out the @Security() decorator, then it'll work as expected. I guess the two don't play nice together?

Also, if you add a await new Promise(resolve => setTimeout(resolve, 1000)) between the time forbiddenResponse() gets called and when we return from the controller, like this:

forbiddenResponse(403, Readable.from('This is a forbidden response'), { 'Content-Type': 'text/plain' });
await new Promise(resolve => setTimeout(resolve, 1000))
return undefined as any;

...then it'll work as expected.

Presumably there's a race condition going on, where the controller is trying to take the undefined return value and convert that to a response, but the forbiddenResponse() function is also trying to send a response at the same time, but forbiddenResponse() will sometimes go too slow when it is given a stream. I don't really know how it's tied to the @Security() decorator - I'm just guessing that it's somehow affecting the timing of events and exposing the race condition.

Here's a dump of all of the files I'm using.

// -- src/app.ts --
import express, {json, urlencoded} from "express";
import { RegisterRoutes } from "../build/routes";

export const app = express();

app.use(urlencoded({ extended: true }));
app.use(json());

RegisterRoutes(app);

const port = process.env.PORT || 3000;

app.listen(port, () =>
  console.log(`Example app listening at http://localhost:${port}`)
);

// -- src/authentication.ts --
export async function expressAuthentication(): Promise<any> {
  return { id: 1, name: "Ironman" };
}

// -- src/UserController.ts --
import {
  Controller,
  Get,
  Res,
  Route,
  Security,
  TsoaResponse,
} from "tsoa";
import { Readable } from "stream";

@Route("users")
export class UsersController extends Controller {
  @Get("get-one")
  @Security("api_key")
  public async getUser(
    @Res() forbiddenResponse: TsoaResponse<403, Readable, { 'Content-Type': 'text/plain' }>
  ): Promise<{ thisIsNeverReturned: true }> {
    return forbiddenResponse(403, Readable.from('This is a forbidden response'), { 'Content-Type': 'text/plain' });
  }
}

// -- tsoa.json --
{
  "entryFile": "src/app.ts",
  "noImplicitAdditionalProperties": "throw-on-extras",
  "controllerPathGlobs": ["src/**/*Controller.ts"],
  "spec": {
    "outputDirectory": "build",
    "specVersion": 3,
    "securityDefinitions": {
      "api_key": {
        "type": "apiKey",
        "name": "access_token",
        "in": "query"
      }
    }
  },
  "routes": {
    "authenticationModule": "./src/authentication.ts",
    "routesDir": "build"
  }
}

// -- tsconfig.json --
{
  "compilerOptions": {
    "target": "es6",
    "module": "commonjs",
    "outDir": "build",
    "lib": ["es2021"],
    "esModuleInterop": true,
    "experimentalDecorators": true
  }
}

I'm running the project with

tsoa spec-and-routes && tsc && node build/src/app.js
github-actions[bot] commented 5 months ago

Hello there theScottyJam 👋

Thank you for opening your very first issue in this project.

We will try to get back to you as soon as we can.👀

github-actions[bot] commented 4 months ago

This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days