Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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
2 of 4 tasks
theScottyJam opened this issue Apr 17, 2024 · 2 comments
Labels

Comments

@theScottyJam
Copy link

theScottyJam commented Apr 17, 2024

Sorting

  • I'm submitting a ...

    • bug report
    • feature request
    • support request
  • I confirm that I

    • used the search to make sure that a similar issue hasn't already been submit

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 #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

  • Confirm you were using yarn not npm: [ ]

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
Copy link

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.👀

Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant