Overview

Call your Vercube API from a frontend, another service or a test, typed by your controllers

The Client module is a small fetch wrapper whose types come from your controller methods. Nothing is written by hand: the response is what the handler returns, the body is what its @Body() parameter accepts, and the path parameters are the :segments of the route. Change a handler on the server and every call that no longer fits stops compiling.

import { createClient } from '@vercube/client';
import type { VercubeRoutes } from '../.vercube/routes.js';

const api = createClient<VercubeRoutes>({ baseURL: 'http://localhost:3000' });

const user = await api.get('/users/:id', { params: { id: '42' } });
//    ^? { id: string; name: string; createdAt: string }

await api.post('/users', { body: { name: 'Ada' } });

It has no dependencies, weighs about 1.5 kB minified and gzipped, and runs anywhere fetch does: browsers, workers, Node, Bun and Deno.

A complete project, a server with a script that calls it, is in examples/client: npx giget gh:vercube/vercube/examples/client vercube-client.

Installation

Install it where the calls are made. In a single repository that is the server project itself.

$ pnpm add @vercube/client

The route map

.vercube/routes.d.ts tells the client which routes exist. It is generated from the running application, so it lists exactly what the router serves, including controllers mounted elsewhere with setControllerPath() and routes registered by plugins.

.vercube/routes.d.ts
// Generated by Vercube from your controllers. Do not edit: `vercube typegen`
// rewrites it.

import type { RouteDef } from '@vercube/client';
import type { UserController } from '../src/Controllers/UserController.js';

export interface VercubeRoutes {
  'GET /users/:id': RouteDef<UserController, 'getUser', { param: { id: 0 } }>;
  'POST /users': RouteDef<UserController, 'create', { body: 0 }>;
}

The file holds no types of its own. Each entry names a controller, a method and the position of each parameter decorator, and RouteDef asks the compiler for the rest. That is why it only changes when routes are added, removed or moved: editing a handler's signature changes the client's types without touching the file.

Once the project depends on @vercube/client, the file is kept up to date for you:

CommandWhen the route map is written
vercube devWhen the dev worker starts and on every restart, if the map it would write differs from the file (a route changed, a controller moved, or the file was deleted)
vercube buildAfter the build
vercube typegenOn demand, also in projects that do not list @vercube/client
Vite pluginIn vite dev after every reload, with the same rule, and after vite build

To generate the map, the built entry is run with introspection on, the same way vercube inspect does, and stopped before it binds a port. Your setup runs, onStart() hooks do not. A setup that has not finished after 60 seconds (waiting on a database that is not there, say) is stopped: the build carries on with a warning and leaves the previous map alone.

Commit the file. A frontend in another package, or CI running tsc, can then type-check without booting the server, and a pull request shows API changes as a diff. The output is sorted, so the same application always produces the same file. Add .vercube/ to your formatter's ignore list (ignorePatterns for oxfmt, .prettierignore for Prettier), otherwise a formatter that sorts imports and the next typegen run keep rewriting each other.

To write it somewhere else, or to turn it off:

vercube.config.ts
export default defineConfig({
  build: {
    typegen: { output: 'web/src/api/routes.d.ts' },
    // or: typegen: false
  },
});

With the Vite plugin, pass the same option to vercube({ typegen: ... }).

A route whose controller cannot be imported is left out with one warning, for instance a controller class that is not exported:

[typegen] Skipped GET /internal/health:
  HealthController is not exported from ../src/Controllers/HealthController.ts

What is typed

Request partComes fromRequired when
paramsthe :segments of the path, typed by @Param()always, every segment
query@QueryParam() and @QueryParams()the parameter type does not allow undefined
headers@Header()the parameter type does not allow undefined
body@Body()the parameter type does not allow undefined
form@MultipartFormData()the parameter type does not allow undefined

The types are those of the handler's parameters, so they are what the handler receives after validation. A schema that transforms its input (z.coerce.number(), .default()) types the call by its output: @QueryParam({ name: 'page', validationSchema: z.coerce.number() }) page: number accepts page: 2, which is sent as ?page=2.

The response is what the handler returns, awaited and as JSON would carry it: a Date becomes a string, methods are dropped. A handler that returns a raw Response gives unknown, void gives undefined, and an @Sse() route gives a stream of typed events, see Requests.

In tests

@vercube/testing exposes the same client on every test app. Its requests go to the app in memory, without a port:

test/users.test.ts
import { createTestApp } from '@vercube/testing';
import type { VercubeRoutes } from '../.vercube/routes';

it('creates a user', async () => {
  await using app = await createTestApp<VercubeRoutes>({
    controllers: [UserController],
  });

  const user = await app.client.post('/users', { body: { name: 'Ada' } });

  expect(user.name).toBe('Ada');
});

Where to go next

  • Requests: bodies, query strings, headers, hooks, errors, server-sent events and aborting.
  • API: every option, method and type.