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

HttpRouter with overlapping routes fails silently #4092

Open
nounder opened this issue Dec 7, 2024 · 4 comments
Open

HttpRouter with overlapping routes fails silently #4092

nounder opened this issue Dec 7, 2024 · 4 comments
Assignees
Labels
bug Something isn't working

Comments

@nounder
Copy link

nounder commented Dec 7, 2024

What version of Effect is running?

3.11.3

What steps can reproduce the bug?

https://effect.website/play/#103e73793389

import { HttpApp, HttpMiddleware, HttpRouter, HttpServerResponse } from "@effect/platform"
import { Effect } from "effect"

const RouteA = HttpRouter.all(
  "*",
  Effect.gen(function*() {
    console.log("RouteA")
    return HttpServerResponse.html("RouteA")
  })
)

const RouteB = HttpRouter.all(
  "*",
  Effect.gen(function*() {
    console.log("RouteB")
    return HttpServerResponse.html("RouteB")
  })
)

export const Router = HttpRouter.empty.pipe(
  RouteA,
  RouteB,
  HttpRouter.use(HttpMiddleware.logger)
)

const handler = HttpApp.toWebHandler(Router)

handler(new Request("http://localhost:8080/"))
  .then(console.log)

What is the expected behavior?

The response is generated by first matched handler. If first handler returns 404 or some kind of an empty response, next match is executed until succesful response is returned.

What do you see instead?

Response is empty has status=500. No handler function is called as there are no logs.

Additional information

There was no error printed or thrown (I tried Effect.tapError)

@nounder nounder added the bug Something isn't working label Dec 7, 2024
@nounder
Copy link
Author

nounder commented Dec 8, 2024

One option is to allow handlers fail with Effect.fail(new RouteNotFound({ request: req })). Router.empty already have this error tag.

@nounder
Copy link
Author

nounder commented Dec 8, 2024

Same behavior can be observed with two wildcard routes using distinct methods:

export const router = HttpRouter.empty.pipe(
  HttpRouter.head("*", ViteDevHttpRouteHandler),
  HttpRouter.get("*", ViteDevHttpRouteHandler),
)

Fundamentally, failure happens when there are more than one catch-all routes.

My app has two catch-all routes: 1) one for SSR, 2) the other for serving assets via Vite. They are added at the end of the router so any hard-coded routes takes precedence.

Workaround: Initially, I created middleware but then I noticed it is not called on every request. I ended up with recovering from RouteNotFound error using a custom logic that return first successful response from route 1 and 2. This is a workaround because it's not composable and it relies on error mechanism.

@tim-smart
Copy link
Contributor

We can have it not throw when you have duplicate routes. Currently it will error like this:

import { HttpMiddleware, HttpRouter, HttpServer, HttpServerResponse } from "@effect/platform"
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"
import { Layer } from "effect"
import { createServer } from "http"

const router = HttpRouter.empty.pipe(
  HttpRouter.all("*", HttpServerResponse.text("first")),
  HttpRouter.all("*", HttpServerResponse.text("second"))
)

HttpServer.serve(router, HttpMiddleware.logger).pipe(
  Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })),
  Layer.launch,
  NodeRuntime.runMain
)
image

If you want to create a middleware that rescues from a route not found:

import { HttpMiddleware, HttpRouter, HttpServer, HttpServerError, HttpServerResponse } from "@effect/platform"
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"
import { Effect, Layer } from "effect"
import { createServer } from "http"

const isRouteNotFound = (u: unknown): u is HttpServerError.RouteNotFound =>
  HttpServerError.isServerError(u) && u._tag === "RouteNotFound"

const someMiddleware = HttpMiddleware.make((httpApp) =>
  httpApp.pipe(
    Effect.catchIf(isRouteNotFound, () => HttpServerResponse.text("fallback"))
  )
)

const router = HttpRouter.empty.pipe(
  someMiddleware
)

HttpServer.serve(router, HttpMiddleware.logger).pipe(
  Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })),
  Layer.launch,
  NodeRuntime.runMain
)

@nounder
Copy link
Author

nounder commented Dec 10, 2024

I ended up with following work around:

/**
 * Runs each route sequentially and returns first successful one.
 */
const FallbackHandler = pipe(
  [
    SolidSsrHandler,
    ViteDevHttpRouteHandler,
  ],
  Arr.map((route) =>
    route.pipe(
      Effect.andThen(
        (res) =>
          res.status === 404
            ? Effect.andThen(
              HttpServerRequest.HttpServerRequest,
              (request) => Effect.fail(new RouteNotFound({ request })),
            )
            : res,
      ),
    )
  ),
  Effect.firstSuccessOf,
)

export const router = HttpRouter.empty.pipe(
  HttpRouter.get("/", HttpServerResponse.text("yo")),
  HttpRouter.all("*", FallbackHandler),
)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

No branches or pull requests

2 participants