honojs / hono

Web framework built on Web Standards
https://hono.dev
MIT License
17.16k stars 484 forks source link

[New Standard] Middlewares that work with any framework (Hono, HatTip, ...) #443

Closed brillout closed 1 year ago

brillout commented 1 year ago

Both Hono and HatTip enable third-party libraries (auth libraries, GraphQL libraries, ...) to provide server middlewares for automatic integration: for example, a GraphQL library providing a Hono middleware enables the user to use that middleware to easily integrate the GraphQL library to the user's Hono app.

Hono and HatTip support a wide range of server-side JavaScript environments (Node.js, Cloudflare Workers, Deno, ...), which means that these middlewares work in any of these environments.

But, as a library author, I wouldn't want to have to write one middleware per framework (a Hono middleware + a HatTip middleware + ...).

Instead I'd want to define a single universal middleware that works with any framework:

// node_modules/some-library/dist/universal-middleware.js

export default myMiddleware({ url }) {
  if (url === '/hello') {
    return new Response("Hello from a universal middleware!");
  }
}
// node_modules/some-library/package.json
{
  "name": "some-library",
  "exports": {
    "./universal-middleware": "./dist/universal-middleware.js"
  }
}

This means that import middleware from 'some-library/universal-middleware' would work with Hono as well as with HatTip.

A couple of thoughts:

Ideally this standard would be part of WinterCG (CC @crowlKats @legendecas @littledivy @lucacasonato @MylesBorins @panva @RaisinTen @ryzokuken @surma @targos @tniessen). (Sorry for the mass pinging; I'm not sure who I should ping.)

CC @cyco130 (HatTip author).

yusukebe commented 1 year ago

It's an interesting idea, but I don't know how to implement it because the way to implement middleware is similar but different. I do not want to change the ecosystem we have created.

What I want is not universal middleware but the "JUST" library that works on any platform. For example, GraphQL.js works on Cloudflare Workers, but I don't know if ajv will work. The real point is that it is easy to create Hono middleware, so if such a library is available, we should only adapt it to Hono.

@metrue @usualoma How do you think about it?

usualoma commented 1 year ago

I agree with @yusukebe.

I also think it is an interesting idea, but I feel that the cost of commonality outweighs the benefits. We only need a good library and users can wrap it as middleware themselves.

metrue commented 1 year ago

It's an interesting topic, but I have a feeling it's not easy to make it with small effort.

If we think of a middleware as a function which takes context as input,

middleware = fn(ctx)

the context is actually the container of FetchEvent and additional information (created and attached by framework), so actually the middleware is,

middleware = fn(FetchEvent + FrameworkCtx)

Due to FrameworkCtxs are way different, it's not easy for middleware developer to make it generic, which is the major block to make a middleware universal available.

But If a middleware is working like this,

middleware = fn(FetchEvent + FrameworkCtx) = fn'(FrameCtx) + fn''(FetchEvent) 

It might be easy to create an adaptor to make a middleware available for different framework, otherwise there are still a lot manual work need to done.

mindplay-dk commented 1 year ago

For what it's worth, having a unified middleware standard (PSR-15) for PHP continues to be quite successful.

image

More than 1100 packages rely on this standard.

The ecosystem fragmentation, the inability to switch from one package to another, the availability of one package for one framework but not another, and so on - these are some of the things keeping me, personally, from recommending Node.JS to, honestly, anyone. There is a lot of turnover in the JS ecosystem. Frameworks come and go. Same with PHP really, but PSR middleware stands and has been stable since it's inception - use middleware from any vendor, switch frameworks freely.

Now that the ecosystem finally seems to have settled on the Request/Response models, it would make sense, and it's probably the right time to come to an agreement on standards for handlers and middleware.

Just my two cents. 🙂

(I was involved in the standardization of PSR-15 and I might be able to help.)

mindplay-dk commented 1 year ago

What if, as a first step, we pushed for an interoperability standard for handlers only?

The thing is, once you get into middleware territory, things start to get rather opinionated.

If you stay in handler territory, it's much less opinionated - a handler is basically Request => Response, that's pretty universal and well understood, and not much reason to have opinions about anything there.

Now, the Request model was designed for incoming requests, and so to make this practical for server-side middleware, you might opt for something like ServerRequest => Response, where ServerRequest might be something similar to your HonoRequest and/or PSR-15's ServerRequest. (it doesn't have to be immutable - I used to have strong opinions about that, but the Node community favors mutable models for the sake of performance I think, and it doesn't make individual or combined middlewares much harder to test, so I'm more or less neutral on this subject by now.)

If we could land on a standard for handlers, different framework flavors of middleware should be rather easy to integrate, as any handler can have a factory function (constructor) that allows it to be middleware - it just needs to accept another handler. (see for example Shelf which defines middleware as essentially just Handler => Handler - a simple decorator pattern, very easy and simple to apply in the context of JS.)

Practical example: I've decided to try out Hono and maybe I'd like to integrate, say, Angular - which, sadly, provides only express middleware. If I want this, I'd have to port it from Express to Hono - which means I have to know all the intricacies of Express and Hono. I would think, if we had the option to ship universal middleware, which would plug into Express, Hono, HatTip, Koa, or anything with an adapter, that would be a much more attractive option for most people, wouldn't it?

I'm tired of choosing PHP only because of middleware. TypeScript is a great language, and frameworks like Hono look awesome, but I don't want to do a lot of reinventing the wheels - and having those wheels fit only one car. 😅

@brillout where is your thinking on this? I'd be very interested in joining an effort to solve this.

mindplay-dk commented 1 year ago

On a related note, here's an attempt at simplifying fetch for the sake of simplifying middleware - it's rough, but the idea here is to removes all the overloads from fetch, reducing it to one consistent signature, and then providing adapters to/from the more complex signature. It's a POC, just enough to illustrate the idea. (Middleware would be extremely complex if it had to handle all these convenience overloads.)

brillout commented 1 year ago

@mindplay-dk I like idea of starting simple with Request => Response and then grow the spec from there.

We can start with vite-plugin-ssr's middleware which is very simple: https://github.com/brillout/vite-plugin-ssr/blob/2b2b48950919da7471bc1160e4a7a2df32eb891e/boilerplates/boilerplate-react/server/index.js#L31-L41.

I can create a import { universalMiddleware } from 'vite-plugin-ssr/universal-middleware' so we can experiment with it and see how it goes. We can then extend the spec to add context for use cases like authentication https://vite-plugin-ssr.com/auth.

Regarding the lower-level aspects of your proposal, I don't have much opinion (yet).

Ping @cyco130 in case you didn't subscribe to the discussion already :-).

@mindplay-dk Ideas for a name? Personally, I'd go for a self explanatory name, e.g. universal-server-middlewares but somehow shorter yet still (somewhat) self explanatory. Random idea: uniPlug. Or Pluglets (but not really self explanatory anymore 😅).

cyco130 commented 1 year ago

I wholeheartedly agree that we need a universal server standard for JavaScript runtimes. But it's hard to build consensus between diverging needs, diverging styles, and the "we already have something working for us" feeling. Of the top of my head, there's Hono, HatTip, SvelteKit adapters, Astro adapters, nitro, and whatwg-node. SolidStart also has its own system but I understand they're rebasing on Astro. I've been in contact with all of them but all have slightly different needs and goals.

Hono team, for instance, clearly stated on top of this thread that a common middleware system is out of scope for them. @mindplay-dk didn't like HatTip's Koa-like mutable context approach and wanted a functional Handler => DecoratedHandler middleware system[^1]. SvelteKit people don't want to break what's working for them. Ditto for nitro, which took its own approach instead of Request => Response.

This is a lot of duplicate effort. That said, the Astro team is more sympathetic and I only recently connected with the GraphQL focused "The Guild", the team behind whatwg-node. So maybe there's still hope :)

[^1]: I purposefully separated the runtime adapters from the middleware system so that alternative middleware systems can be developed. But I believe that the mutability off most builtin APIs (URL, Headers etc.) make a functional approach less attractive here. Mutable context has become the accepted JS way (Express, Fastify, etc.). That said, I can see myself building a DI system on top of it if I were to build anything of scale.

brillout commented 1 year ago

I think if we can manage to convince a couple of successful projects (e.g. Auth.js) to ship a "universal" middleware, then we can get the ball rolling. The thing here is that library authors do not want to implement a middleware for each JavaScript server framework out there.

So we first win over library authors, then we win over framework authors. We don't have to convince framework authors for now as we can implement server framework adapters without talking to these frameworks.

But, yea, it's definitely a long haul thing.

(On my side I'm quite busy and I've only limited resources. Hopefully this will change.)

cyco130 commented 1 year ago

I think if we can manage to convince a couple of successful projects (e.g. Auth.js) to ship a "universal" middleware

The problem is, Auth.js, GraphQL libraries, vite-plugin-ssr etc. don't really need universal middleware. They need universal handlers which have been already standardized on Request => Awaitable<Response>. It works everywhere except Node and there are many adapters for Node too.

Auth.js, for example, already uses Request => Promise<Response> so we can use it from any runtime. But it's a black box handler, its cookie and session middleware are not available from the outside, for instance. That is if they do have a middleware system at all. They probably just call a parseCookies(request) function. Which is maybe where we are headed as an ecosystem. We'll see :)

mindplay-dk commented 1 year ago

The problem is, Auth.js, GraphQL libraries, vite-plugin-ssr etc. don't really need universal middleware. They need universal handlers which have been already standardized on Request => Awaitable<Response>. It works everywhere except Node and there are many adapters for Node too.

Yeah, as said, I don't think anyone needs universal middleware - and I'm not sure it's a realistic pursuit, as it tends to get extremely opinionated.

I didn't realize projects were already starting to standardize on Request => Awaitable<Response> - that's great news, that's basically all I was suggesting or hoping for.

So I was wondering how come the Angular team doesn't do that, and by the looks of it, they're using and exposing the Express Request/Response types - unfortunately, these are not implementation details, so they become the actual dependencies of Angular based projects. There's no adapter fix for that, I'm afraid. They've chosen Express not as a detail but as their framework. It seems the only way out of that would be breaking changes, switching from Express to the fetch model.

Well, hopefully Request => Awaitable<Response> becomes more widely used and takes over the world. 😄

mindplay-dk commented 1 year ago

Interestingly, I poked through Hono's built-in middleware, as well as the third-party middleware listed in the documentation - almost all of it uses the Context argument only to look up the Request and Response.

Notable exceptions are things like encoding JSON or HTML responses, which could have been provided as plain functions - and the Sentry middleware, which uses Context as a kind of service locator, to make the Sentry service available, which could have been done with dependency injection instead.

Mostly what's in Context seems like it could have been put into helpers instead - it seems to be mostly utilities to help end users writing controller functions. It's rarely if ever used by middleware.

Just my superficial analysis, but it seems like the middleware interface here is coupled to more things than it needs to be - and likely all of the middleware could have been "universal", if that meant something like Request => Awaitable<Response>.

brillout commented 1 year ago

For context how about (Request, context: Record<string, unknown>) => Promise<Response> where context is just a plain JavaScript object that library middlewares mutate?

➡️ Using ES6 Proxy, the server framework (Hono/HatTip/...) can intercept mutations and manage context in an immutable way.

It can be worth it to write a little "spec" for such context convention and also to rise awareness. I'm only aware of Auth.js who uses Request => Promise<Response). So the spec can act as as call to library authors to expose a Request/Response middleware so that users can use their library with any server framework/environment they want. E.g. for the Angular team (Angular users will demand this from the Angular team, let alone to be able to deploy to Cloudflare Workers).

brillout commented 1 year ago

For context how about (Request, context: Record<string, unknown>) => Promise<Response> where context is just a plain JavaScript object that library middlewares mutate?

The goal here being to enable Auth.js to expose context.user.

mindplay-dk commented 1 year ago

For a modern standard, I think I would propose Record<symbol, unknown> for the context - this guarantees no surprise collisions between modules, enables private message passing between related middleware, etc.

cyco130 commented 1 year ago

The goal here being to enable Auth.js to expose context.user.

That would be an awesome start of course :)

For a modern standard, I think I would propose Record<symbol, unknown> for the context - this guarantees no surprise collisions between modules, enables private message passing between related middleware, etc.

You can do it in HatTip today, it's just an object either way.

We could consider string keys to be reserved for "official" system-level stuff (cookie, session, router stuff etc.). There's a special locals key reserved for app-level stuff. And I'm about to add one for framework-level stuff (e.g. for Rakkas). Other, specialized middleware can use symbols.

But, although I understand the worry, I really doubt name collisions have been a real problem for the ecosystem in 10+ years of Express, to be honest.

Btw, I also agree response constructors (c.json({ hello: "world"})) should rather be simple functions (they are in HatTip).

One more idea on symbols: We could turn this around and put the context inside of the response object under a symbol (or use a weak map) and provide back and forth converters between the two formats for interoperability with pure Request => Response systems (e.g. Astro and Auth.js).

mindplay-dk commented 1 year ago

Heh, well, Express users have put up with a lot of nonsense for 10+ years. 😅

What if Record<symbol, unknown> is where the standard proposes we put userland context?

This would leave the string space for reserved platform interop features.

So something like:

type Context = Record<symbol, unknown> & PlatformContext;

type PlatformContext = {
  glob: (pattern: string) => FileInfo[];
  open: (path: string) => ReadableStream;
  // ...
}

interface FileInfo {
  path: string;
  // ...
}

So PlatformContext would be defined for the standard as well, enabling us to write actually useful cross-platform handlers for things like serve-static which is currently handcrafted for each platform. (which turns out to be more work than you might have anticipated - serving a file well isn't actually that simple.)

If we had some basic functions for listing files, getting file stats, and opening a file, we'd already be able to do a lot - not just static file serving, but also guarded downloads, proxies, etc.

I mean, ideally, these features should be defined by Web standards, and we should just say "use those", but the reality is, support for web standards is still very spotty on many of the platforms supported by Hono and HatTip. Having a baseline set of functions for basic file IO would enable (or require) some of these other platforms to polyfill - and where they can't polyfill, it would enable them to at least throw descriptive exceptions.

I'd love to hear what you guys think?

cyco130 commented 1 year ago

I'd rather have a common platform module. I think of context as the request context, i.e. things that change from request to request. HatTip's ctx.platform basically holds the platform specific request representation.

mindplay-dk commented 1 year ago

I think of context as the request context, i.e. things that change from request to request.

Yeah, good point - that makes sense.

The problem with modules is they complicate testing - I'd like to devise something that relies on dependency injection (a proper platform abstraction) which would simplify both testing and bootstrapping in general.

What if we separated "request context" from "platform context"?

type RequestContext = Record<symbol, unknown>;

type PlatformContext = {
  // ...
}

type HandlerFactory = (platform: PlatformContext) => Handler;

type Handler = (request: Request, context: RequestContext) => Response;

Or using a single structural type:

type Handler = (platform: PlatformContext) => (request: Request, context: RequestContext) => Response;

So a platform implementation would provide an implementation of PlatformContext, and this would get injected to every Handler at some point during startup. (at bootstrap time, or maybe lazily.)

Thoughts?

yusukebe commented 1 year ago

Hi there.

That is very interesting but is getting out of Hono's scope. I don't want to create a standard, I want to create Hono. We just want to talk about Hono's middleware, not about standardizing middleware.

If you want to continue the discussion, please do it elsewhere. I'll close the issue.

cyco130 commented 1 year ago

If you want to continue the discussion, please do it elsewhere. I'll close the issue.

Sorry for hijacking :) @brillout @mindplay-dk and anyone who wants to continue, we can continue here: https://github.com/hattipjs/hattip/discussions/45

yusukebe commented 1 year ago

Thanks for understanding :)

brillout commented 2 months ago

For those interested, there is new work-in-progress about universal handlers/middlewares: