honojs / hono

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

trpc adapter #582

Closed aulneau closed 1 year ago

aulneau commented 2 years ago

hi again :)

so in exploring trpc, they have some adapters, I took a stab at modifying their fetch adapter to work with hono, but I'd love some feedback on if this looks okay:

import { AnyRouter, inferRouterContext, resolveHTTPResponse } from '@trpc/server';
import { HTTPBaseHandlerOptions, HTTPRequest } from '@trpc/server/dist/http/internals/types';
import type { Context,  } from 'hono';
import { StatusCode } from 'hono/utils/http-status';

export type HonoCreateContextFn<TRouter extends AnyRouter> = (opts: {
  ctx: Context;
}) => inferRouterContext<TRouter> | Promise<inferRouterContext<TRouter>>;

export type HonoCreateContextOption<TRouter extends AnyRouter> =
  unknown extends inferRouterContext<TRouter>
    ? {
        /**
         * @link https://trpc.io/docs/context
         **/
        createContext?: HonoCreateContextFn<TRouter>;
      }
    : {
        /**
         * @link https://trpc.io/docs/context
         **/
        createContext: HonoCreateContextFn<TRouter>;
      };

export type HonoHandlerOptions<TRouter extends AnyRouter> = HTTPBaseHandlerOptions<
  TRouter,
  Request
> &
  HonoCreateContextOption<TRouter>;
export type HonoHandlerRequestOptions<TRouter extends AnyRouter> = {
  ctx: Context;
  endpoint: string;
} & HonoHandlerOptions<TRouter>;

export async function honoRequestHandler<TRouter extends AnyRouter>(
  opts: HonoHandlerRequestOptions<TRouter>
): Promise<Response> {
  const createContext = async () => {
    return opts.createContext?.({ ctx: opts.ctx });
  };

  const url = new URL(opts.ctx.req.url);
  const path = url.pathname.slice(opts.endpoint.length + 1);
  const req: HTTPRequest = {
    query: url.searchParams,
    method: opts.ctx.req.method,
    headers: Object.fromEntries(opts.ctx.req.headers),
    body: await opts.ctx.req.text(),
  };

  const result = await resolveHTTPResponse({
    req,
    createContext,
    path,
    router: opts.router,
    batching: opts.batching,
    responseMeta: opts.responseMeta,
    onError(o) {
      opts?.onError?.({ ...o, req: opts.ctx.req });
    },
  });

  for (const [key, value] of Object.entries(result.headers ?? {})) {
    if (typeof value === 'undefined') {
      continue;
    }

    if (typeof value === 'string') {
      opts.ctx.header(key, value);
      continue;
    }

    for (const v of value) {
      opts.ctx.header(key, v);
    }
  }
  return opts.ctx.body(result.body, result.status as StatusCode);
}

Usage:

import { serve } from '@honojs/node-server';
import { Hono } from 'hono';
import { logger } from 'hono/logger';
import { initTRPC } from '@trpc/server';
import { honoRequestHandler } from './common/trpc';

const t = initTRPC.create();

const appRouter = t.router({
  userById: t.procedure
    .input((val: unknown) => {
      if (typeof val === 'string') return val;
      throw new Error(`Invalid input: ${typeof val}`);
    })
    .query(req => {
      const { input } = req;

      return { hello: 'world' };
    }),
});

const app = new Hono();

app.use('*', logger());

app.get('/', c => c.text('working :~)'));

app.use('/trpc/*', c => {
  return honoRequestHandler({
    router: appRouter,
    ctx: c,
    endpoint: 'trpc/',
  });
});

serve(app);
yusukebe commented 2 years ago

Hi @aulneau !

Wow, this is great!!

I have just used tRPC for the first time, and it is interesting. Hono's advantage is that Hono can run on both Node.js and Cloudflare Workers or Bun and Deno.

It is also good to make it like Middleware.

export const trpcServer = <TRouter extends AnyRouter>(opts: { router: TRouter, endpoint: string  }): MiddlewareHandler => {
  return async (c, next) => {
    c.res = await honoRequestHandler({
      router: opts.router,
      ctx:c,
      endpoint: opts.endpoint || 'trpc/'
    })
    await next()
  }
}

In script:

import { trpcServer } from './trpc-server';

//...

app.use('/trpc/*', trpcServer({ router: helloRouter, endpoint: 'trpc/' }))
// Can we set `endpoint` automatically?

I think it would be good to have the code placed in the repository of tRPC itself.

aulneau commented 2 years ago

yeah! trpc is very popular and very good! I think it'd be awesome if hono supported it (or vis versa)

I was thinking middleware would be better, I wasn't sure what style of API would work best. I'd love to defer to you on that :) my implementation was a quick and dirty one.

I think it would be good to have the code placed in the repository of tRPC itself.

agree -- but short-term, I wonder if it could also live in the hono repo/org? @honojs/trpc-adapter?

yusukebe commented 2 years ago

I wonder if it could also live in the hono repo/org? @honojs/trpc-adapter?

Ah, yes. It is also good to make it @honojs/trpc-adapter. Both are good.

Since tRPC is so popular, I think it would be better to have it placed in that tRPC repository because it would be used by more people :)

For example, we could create @honojs/trpc-adapter and put examples of Hono in tRPC examples.

By the way, this is a different issue, but I am thinking of changing the way to manage Third-party Middleware. Specifically, I'm thinking of making it monorepo. In that case, it may take some time to create @honojs/trpc-adapter.

aulneau commented 2 years ago

agree -- i think in both places makes sense. @yusukebe do you have any modifications to the API (eg: making it middleware)?

yusukebe commented 2 years ago

What I would like to do is just add the following code and make it middleware.

https://github.com/honojs/hono/issues/582#issuecomment-1267006380

@aulneau

Do you try to make @honojs/trpc-adapter? This means you can develop the middleware in the honojs/org repository such as github.com/honojs/trcp-adapter, and you will release it to the npm registry. If you want to do it, I will prepare the repository for you. Or I can do it. Or it's OK the different way you want!

aulneau commented 2 years ago

@yusukebe yes I'd be happy to, feel free to create it and I can create a PR (as collaborator or fork)

yusukebe commented 2 years ago

Alright. But please wait a minute. I'm thinking about how to manage third-party middleware again. We may go with it as is or use monorepo.

OnurGvnc commented 2 years ago

monorepo +1

cleaton commented 2 years ago

Instead (or rather in addition too) of integrating with TRPC, might it not be better to just create a Proxy client that is native for Hono? I think that will be much simpler and more reliable (not break when TRPC changes things.)

I spent some time trying to extract client interface type that can be used to build the fetch API request inside a Proxy handler using Typescipt autocomplete, ex:

image

I think for someone with more experience in the Hono code it won't be hard, my basic implementation is only ~40 lines but it probably can be much better.

edit: final usage if there was a client Proxy implementation:

type MyAPI = typeof routes

----- Only import type into frontend code -----
const client = honoClient<MyApi>('http://my-url.com/api')
const biz: {bid: string} = await client.run()['/api/business/:id'].post({id: '123'})
------ or maybe better with something like this for path arguments? ----
const biz: {bid: string} = await client.run().business().id('123').post()
cleaton commented 2 years ago

I've uploaded the change I have so far. https://github.com/cleaton/hono/tree/hono-client Essentially just need a way for typescript to keep all the paths, arguments(optional or not) (path, body, query and maybe headers) and return type (before converting to json/response).

Currently I do this by creating a factory class that unions previous paths every time adding a new one. The existing HandlerInterface on Hono class might be able to do this as well since it already returns this (instead of this, return a new instance with updated generic types). Or just have a completely new interface for this use-case.

yusukebe commented 2 years ago

@cleaton !!

Wow cool!! I've tried it, it's like a tRPC but just using only Hono!

This is the usage, right?

output

This is great; it is simpler to write than the code for using tRPC. And when combined with the Validator middleware, it becomes even more amazing. The tRPC adapter works well for users who are used to using @trpc/client , but for those who are not familiar with it, this approach may be better.

We shouldn't try to do a lot of things in Hono itself, but I like it.

@aulneau cc: @OnurGvnc @usualoma What do you think about it?

OnurGvnc commented 2 years ago

@cleaton This is great!

And when combined with the Validator middleware, it becomes even more amazing. The tRPC adapter works well for users who are used to using @trpc/client , but for those who are not familiar with it, this approach may be better.

@yusukebe this approach makes a lot of sense

from my point of view; I'm currently doing a lot of fetch operations between cloudflare workers, so this could be a great option for my use case.

yusukebe commented 2 years ago

And how about you? : @ThatOneBro

cleaton commented 2 years ago

@yusukebe yes exactly like that :+1: I think trpc and hono to a large degree is the same thing, an endpoint router definition except that trpc has automatic client creation as well. I've actually been working on an TRPC alternative myself as I found ergonomics not to my liking when working on cloudflare/other edge frameworks. It's can be surprisingly simple to create something like TRPC, just requires some typescript vodoo (I manage to do it as a complete beginner in typescript)

I came across this ticket because I was looking at how to do Google JWT auth middleware and was looking at Hono implementation. It made me think that maybe hono could just have a client instead.

Here's my current implementation: https://github.com/cleaton/type-knit example of a client proxy that recursively builds the fetch request https://github.com/cleaton/type-knit/blob/main/src/client.ts#L74

I also have a stream implementation, which probably should just use EventSource instead to be even simpler

aulneau commented 2 years ago

I think this approach is great too, but I think trpc should also be supported, as they have a large ecosystem of tooling. For folks who aren't using trpc already, this approach could get them very close to a similar experience.

cleaton commented 2 years ago

As reference, this approach is also very similar to “itty-durable”, wrapping durable object fetch API in a proxy https://github.com/kwhitley/itty-durable

yusukebe commented 2 years ago

@aulneau

but I think trpc should also be supported, as they have a large ecosystem of tooling. For folks who aren't using trpc already, this approach could get them very close to a similar experience.

Yes! I agree. tRPC adapter shold be implemented.

@cleaton

Do yow know "Validator Middleware"?

https://honojs.dev/docs/builtin-middleware/validator/

It sounds complicated, but it would be great if we could get the validator and your idea together. Both input and output values can be type checked from the client.

// Server

app.post(
  '/posts',
  validator((v) => ({
    post: v.object('post', (v) => ({
      id: v.json('id').asNumber().isRequired(),
      title: v.json('title').isRequired(),
    })),
  })),
  (c) => {
    const res = c.req.valid()
    const newPost = createPost(res.post)
    return c.json(newPost)
  }
)

const routes = app.build()

export type AppRouter = typeof routes

// Client

class Client<T> {
  baseURL: string
  constructor(baseURL: string) {
    this.baseURL = baseURL
  }
  run: () => T
}

const client = new Client<AppRouter>('https://example.com/api')

const tmp = client.run()
const newPost = tmp['/posts'].post({ post: { id: 123, title: 'Hello!' } })

console.log(`${newPost.id} is created!`)
cleaton commented 2 years ago

@yusukebe yes, I did try a bit but nothing functioning yet. My current implementation has a type conflict when adding middlewares.

I think in theory, once the types are matching correctly we can just use D extends ValidatedData generic type for the post argument. It's a little bit complicated because Hono API treats everything as generic ...handlers: Handler<> even if it's a middleware or other.

TRPC for example uses something like builder().input(validationF).handler(args => ) and in my implementation builder().(Validator<T>, handler: (args: T) => ...) with Validator = { validate: <T>(args: unknown): T }

ThatOneBro commented 2 years ago

Oh this looks great! I'd love to see something like this built into Hono. Looks like it could be a be a great addition to the Hono toolset!

Also @yusukebe, regarding the monorepo idea -- I've just been learning about TurboRepo and other monorepo solutions (such as publishing/ version management through changesets), it may be worth it to go over these ideas sometime if you'd like 😄 I think a monorepo would be great to keep us in sync with all related packages

cleaton commented 2 years ago

I got it working but currently it's ugly. The problems:

  1. Since I use a special function which is not a Handler (returning Response), it does not match against existing middelware functions.
  2. To solve this I separate middleware functions as a second argument however, since middleware function will contain the concrete ValidatedData type it looks like it need to come before the main handler, or the type inference will complain they are not of the same type.
  3. The validator middleware does not return a clean type, instead It's a SchemaToProp with a buch of nested VString, VNumber etc. so looks bit ugly in the vscode autocomplete.

image

On other note, regarding multiple packages in a monorepo. Is it really needed with modern bundlers that supports 'tree shaking' for ES modules? That way the repo can be simpler (no mono repo tools and multiple packages) but at same time can keep all functionality in one place.

thelinuxlich commented 2 years ago

Another library you might look for inspiration is Zodios, like Hono validator it can receive type definitions for headers, query, body, path params, etc and autocomplete on the client-side. Unlike tRPC, it's just REST and can be inspected with tools like Postman, Insomnia, etc

yusukebe commented 2 years ago

On other note, regarding multiple packages in a monorepo. Is it really needed with modern bundlers that supports 'tree shaking' for ES modules?

I think it's not needed, so I think we can go with only yarn workspaces and changesets.

yusukebe commented 2 years ago

Hi everyone!!

Very interesting discussion! I created the following Issues about the feature suggested by @cleaton and the topic of monorepo. Let's talk about them there.

yusukebe commented 2 years ago

Hi @aulneau !

I've made the monorepo for third-party middleware. This was just created, so let's make it better together.

https://github.com/honojs/middleware

Then, there is a skeleton project for tRPC adapter middleware:

https://github.com/honojs/middleware/tree/main/packages/trpc-adapter

Do you write the code for the tRPC Adapter? If so, please clone and create a PR. We may set up CI and releases, etc., so let's do it together. If you don't write the code, I can write it instead. Let's go!

yusukebe commented 1 year ago

Hi there! I've created and released "tRPC Server Middleware". Check it!

https://www.npmjs.com/package/@hono/trpc-server https://github.com/honojs/middleware/tree/main/packages/trpc-server

Braden1996 commented 1 year ago

Hi there! I've created and released "tRPC Server Middleware". Check it!

https://www.npmjs.com/package/@hono/trpc-server https://github.com/honojs/middleware/tree/main/packages/trpc-server

Just tried it; works great for me! Thanks :)

yusukebe commented 1 year ago

I'll close this issue.

vittis commented 8 months ago

Is this compatible with trpc v11? (https://trpc.io/docs/migrate-from-v10-to-v11)

I'm getting a "unmet peer @trpc/server@^10.10.0".

But besides that, seems to work fine. So maybe update that peer dep?

yusukebe commented 8 months ago

Hi @vittis

But besides that, seems to work fine. So maybe update that peer dep?

Yes maybe. But I don't have enough time to check the behavior to use the new tRPC. If you will do it and it works, please create the PR to update it!