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

Pure RPC client types #2489

Closed
askorupskyy opened this issue Apr 9, 2024 · 24 comments
Closed

Pure RPC client types #2489

askorupskyy opened this issue Apr 9, 2024 · 24 comments
Labels
enhancement New feature or request.

Comments

@askorupskyy
Copy link
Contributor

askorupskyy commented Apr 9, 2024

What is the feature you are proposing?

The problem

I was using Hono for one of my monorepo projects and one of the problems I have is that it fails to compile whenever the process environments are different.

Some to code to better explain:

API environment

This below contains all sorts of stuff the API might need, like DB connection strings, payment credentials...

declare global {
  namespace NodeJS {
    interface ProcessEnv {
      API_URL: string;
      APP_URL: string;
      REDIS_URL: string;
      MYSQL_URL: string;
      // etc....
    }
  }
}

Nextjs web client environment

declare global {
  namespace NodeJS {
    interface ProcessEnv {
      API_URL: string;
      APP_URL: string;
    }
  }
}

Compile issues:

How since the Hono RPC client is just the representation of the API, it should not care for missing environment type definitions on the backend.

However, when compiled, (I use Nx with esbuild), the frontend application complains about missing backend environment types, even though this stuff should be abstracted.

I should not need the REDIS_URL to be in process.env within my Nextjs app, however, Hono makes me do this...

Possible fix:

Now I've done some digging and it does not make sense to build ALL of the routes, including their actual implementations in order to have a client type.

  • This slows down the tsc compilation times
  • Makes it harder to separate client and non-client environment, therefore harder to maintain.

It would make much more sense to have a route type, aka input->output definition, from which the client would be constructed. The actual routers would then import this type and have the actual logic.

This, in pair with zod-openapi package would make Hono a killer RPC platform.

How do Hono's competitors achieve this?

I've also been using https://ts-rest.com/, which follows pretty much the same pattern I'm proposing here. The only difference is ts-rest follows full OpenAPI specs for their API definitions.

For example,

This code below describes the type of the router (the client inherits directly from here...)

export const contract = c.router({
  createPost: {
    method: 'POST',
    path: '/posts',
    responses: {
      201: PostSchema,
    },
    body: CreatePostSchema,
    summary: 'Create a post',
  },
})
@askorupskyy askorupskyy added the enhancement New feature or request. label Apr 9, 2024
@askorupskyy
Copy link
Contributor Author

How a found a little way to make this work:

Below is a pure type that does not care about the contents of route implementation.

For now I am just going to create a separate library with such types for each router...

export type AppType = Hono<
  Env,
  ToSchema<
    'get',
    '/hello',
    {
      query: {
        test: string;
      };
    },
    Promise<
      TypedResponse<{
        hello: string;
      }>
    >
  >,
  '/'
>;

const client = hc<AppType>('localhost');

client.hello.$get({ query: { test: 'hello' } });

@askorupskyy
Copy link
Contributor Author

askorupskyy commented Apr 9, 2024

I tried the same thing but with @hono/zod-openapi, and here's what I got

const route = createRoute({
  method: 'get',
  path: '/hello',
  request: {
    params: z.object({ test: z.string() }),
  },
  responses: {
    200: {
      content: {
        'application/json': {
          schema: z.object({ hello: z.literal('world') }),
        },
      },
      description: 'Get hello!',
    },
  },
});

const api = new OpenAPIHono();
const APP = api.openapi(route, function () {} as any);

const client = hc<typeof APP>('localhost');
const req = await client.hello.$get({ param: { test: '123123' } });


const res = await req.json();
// === "world"
console.log(res.hello)

This gave me full control over the types I have in my client, so I managed to create a whole client without any actual code.

This same route variable could now be used in a separate lib/file to actually implement this endpoint.

@yusukebe
Copy link
Member

Hi @askorupskyy

Is the matter resolved? If not, please provide a minimal project to reproduce it. I'll investigate.

@askorupskyy
Copy link
Contributor Author

I'll make a project and tag you in a couple hrs.

I found a hack around original Hono type behavior but this would be nice to have as a part of the framework.
Essentially the problem is in order to create the AppType that you'd use in hc, Hono needs to compile every single type that's used in the router (even when it does not affect the input/output), even though it's not really needed for the client to work properly. What I propose is some way to create this AppType without actually writing the router logic, which I was able to achieve in the examples above.

This little hack that I put above worked for me and actually reduced the amount of .ts stuff needed to be compiled for the frontend. I use prisma for my project so that was significant.

@askorupskyy
Copy link
Contributor Author

Hi @askorupskyy

Is the matter resolved? If not, please provide a minimal project to reproduce it. I'll investigate.

https://github.com/askorupskyy/hono-rpc-types

I created a simple project that explains the issue. Follow the README and the code

@yusukebe
Copy link
Member

@askorupskyy

Thanks!

@askorupskyy
Copy link
Contributor Author

I read through some more issues and I think using my proposal would also fix #1921. Seems like one of the cases I covered in the README to my repo

@yusukebe
Copy link
Member

@askorupskyy

Hmm. I'm trying it, but I'm not so familiar with Nx, and I can't understand what causes the issue. I think it's fine to simply make it emit d.ts and import the file into the client.

@askorupskyy
Copy link
Contributor Author

@yusukebe Nx in my case is just used to demonstate some of the bugs the current type system might cause. The main problem is Hono client needs to infer all the types from routes directly, which causes all of the code inside .get(), .post() and etc. to be compiled by tsc.

At my company we've been using tRPC which had a similar issue. It was building too many types and causing our builds to fail due to lack of RAM. We later switched to ts-rest, which creates the types in advance, similar to what #1921 is proposing,

RPC client's type should be known beforehand so that I can declare it in a global app.d.ts file and pass it int the request.locals to get access in the entire app.

Essentially what I did in this repo is a created the AppType myself using @hono/zod-openapi lib and it allowed me to create a client without compiling the API implementation myself.

It would be nice to declare your route types in Hono the same way as in ts-rest.

@askorupskyy
Copy link
Contributor Author

askorupskyy commented Apr 13, 2024

This syntax would work better:

// declare a router
const createUserRouter = createRouter({
  method: 'POST',
  input: // either a TS type or a zod object
  output: // just a TS type
})
// implement a router
// use generics to make sure what you are implementing matches the type of the declaration
const router = new Hono().post<typeof createUserRouter>('/user', (c) => c.json(new User()))
// create a client
const client = hc<typeof createUserRouter>();

This way the client only depends on the type of the declaration, not the entire business logic of the app.
Also this will speed up the Intellisense for the client, since on every change it's not going to have to recompile the entire business logic

@yusukebe
Copy link
Member

@askorupskyy Thanks for explanation.

In the future, we may add a Validator that validates Response (#2439), so if we can infer the type from that Validator, we will not need to generate the type from the entire Hono application.

@yusukebe
Copy link
Member

Either way, this is not a problem that can be solved right away. I'd like to take some time to think about it.

@yusukebe
Copy link
Member

Plus. As you @askorupskyy mentioned above, the one way to solve this issue is using Zod OpenAPI. Because it is designed to declare endpoint types explicitly. So, we may not have to make Hono core's type inferences more complicated to support this matter. Just use Zod OpenAPI.

@askorupskyy
Copy link
Contributor Author

askorupskyy commented Apr 13, 2024

@yusukebe thank you so much! i did not initially realize zod-openapi resolves this matter completely

@fzn0x
Copy link
Contributor

fzn0x commented May 3, 2024

Hi @yusukebe and everyone, I think this issue solved in #2499 in case you need help to track the progress of this issue, correct me if I'm wrong. ;)

@yusukebe
Copy link
Member

yusukebe commented May 3, 2024

Hi @fzn0x

#2499 does not resolve this. This issue concerns whether or not we can take types from each route. It is not related to it. Either way, Zod Open API has solved this, so we may close this.

@bruceharrison1984
Copy link
Contributor

bruceharrison1984 commented May 3, 2024

@yusukebe, I believe this change may have broken ZodOpenAPI type inferrence:
honojs/middleware#496

@askorupskyy
Copy link
Contributor Author

@yusukebe I spoke with a few more people encountering the same issue as I did (not only with Hono, but tRPC, Elysia and etc), so I wrote a little library that solves this matter for me & my team. It's built on top of @hono/zod-openapi (naming conventions and code style heavily inspired by ts-rest) and creates a pure client type like I wanted. The actual router implementation then imports this type and has auto-complete whenever writing the business logic. Now I can publish my Hono client as an npm package without needing to tsc the entire backend.

The code for this thing looks something like this:

Screenshot 2024-07-15 at 8 36 23 AM

Router implementation on the right, definition on the left. Both are in separate buildable npm libs for faster build times and reusability.

The main difference is now I only need to build the code on the left in order to have an API client, which was not possible before. This resulted about 1/3 speedup in build time for our project and not exposed any backend code/types to the web client except for the actual route definition.

I do not think it's that much of a breaking change to Hono, but it definitely solves a good chunk of problems for its community. I will post this library on my account whenever it's polished enough to be used. Let me know if you have any questions.

@yusukebe
Copy link
Member

Hi @askorupskyy

Does it generate a d.ts file defining the types? I want to use it and see it!

@firxworx
Copy link

@askorupskyy
Copy link
Contributor Author

Sorry for disappearing guys, a lot of stuff happened.

Either way, I found a way to make everything work, and after quite a long pause I am able to continue working on this little library.

@firxworx @askorupskyy have you seen https://github.com/msutkowski/ts-rest-hono ?

Yeah, it does exactly what I am talking about. I just don't like how it's not maintained anymore. Also middleware is not really supported well either.

@yusukebe Hi @askorupskyy Does it generate a d.ts file defining the types? I want to use it and see it!

No, everything is done through type inference. I essentially just made a bunch of wrappers for HonoZodOpenAPI that allow to work with types better. Also got middleware context to work, a bunch of people were complaining about it as well. Looks kinda ugly right now, the code is really hacky, but hopefully will get it to work real soon.

I was able to save a bunch of time on builds, as well as keep the global types different on backend and frontend.

Screenshot 2024-08-30 at 6 07 49 PM

@fortezhuo
Copy link

fortezhuo commented Sep 17, 2024

I come with "dumb newbie" solution instead rewrite all into zod-openapi

Just create define the type api

export const apiRouter = new Hono()
  .route("/approval_module", approval_module)
  .route("/auth", auth)
  .route("/category", category)
  .route("/captcha", captcha)
  .route("/mail", mail)
  .route("/province", province)
  .route("/user", user)
  .route("/vendor_account", vendor_account)
  .route("/vendor_approval", vendor_approval)
  .route("/vendor_info", vendor_info)
  .route("/workflow", workflow)

// export type ApiRouter = typeof apiRouter

export type Api = {
  approval_module: typeof approval_module
  auth: typeof auth
  category: typeof category
  captcha: typeof captcha
  mail: typeof mail
  province: typeof province
  user: typeof user
  vendor_account: typeof vendor_account
  vendor_approval: typeof vendor_approval
  vendor_info: typeof vendor_info
  workflow: typeof workflow
}

and consume the type Api by create wrapper helper function

export const api = <Name extends keyof Api>(name: Name) => {
  return hc<Api[Name]>(`${import.meta.env.VITE_HOST}api/${name}`, {
    init: { credentials: "include" },
  })
}

And we can consume like this

api("category").method.$post({....})

Hope this help

@yusukebe
Copy link
Member

@Liamandrew
Copy link

@askorupskyy Are you interested in publishing or sharing the implementation of createOpenAPIContract & createOpenAPIRoute in order to improve your typescript build times?

I'm also coming from ts-rest land and have my code set up in that convention, but I'm struggling with my IDE build times and wondering if the approach you're describing to type inference will resolve that

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

No branches or pull requests

7 participants