gRPC — Connect JSON, no protobuf
@tahanabavi/typefetch-grpc speaks Connect's JSON protocol by default: a
plain POST /<service>/<rpc> with the message as the body, real HTTP status
codes, curl-able. That is an HTTP route, so NestJS can serve it — with no
protobuf runtime, no .proto file and no code generation anywhere.
import { Controller } from "@nestjs/common";
import { ContractInput, InferRequest } from "@tahanabavi/typewire-nestjs";
import { GrpcEndpoint, GrpcException, GrpcCode } from "@tahanabavi/typewire-nestjs/grpc";
@Controller() // prefix-less: the service name
export class UserRpcController { // fully qualifies the route
@GrpcEndpoint(contracts.user.getUser) // POST /user.v1.UserService/GetUser
async getUser(@ContractInput() input: InferRequest<typeof contracts.user.getUser>) {
const user = await this.users.byId(input.id);
if (!user) throw new GrpcException(GrpcCode.NotFound, `No user ${input.id}`);
return user; // validated against `response`
}
}Failures are named in gRPC's key space, which is what a gRPC endpoint's
errors map is keyed by. Throw a GrpcException to say exactly which code, or
throw the NestJS exception you already would and let it map:
| Handler throws | Code | HTTP |
|---|---|---|
GrpcException(GrpcCode.NotFound) | not_found | 404 |
BadRequestException / a contract violation | invalid_argument | 400 |
UnauthorizedException | unauthenticated | 401 |
ForbiddenException | permission_denied | 403 |
NotFoundException | not_found | 404 |
ConflictException | already_exists | 409 |
| anything else | internal | 500 |
That table is deliberately not the client's codeFromHttpStatus. There, a 404
came from something that never reached the RPC, so it means "no such method" —
unimplemented. Here it came from a handler that looked and did not find, which
is not_found. Same status, opposite meaning, because the thrower is different.
Deadlines are enforced, not just received. The client sends the contract's
deadlineMs as Connect-Timeout-Ms (and grpc-timeout); a deadline only the
client honours is a timeout — the caller gives up and the server keeps the
database connection. @GrpcDeadline() hands the handler an AbortSignal so the
work can stop too:
@GrpcEndpoint(contracts.report.build)
build(@ContractInput() input, @GrpcDeadline() deadline?: GrpcDeadlineInfo) {
return this.db.query(sql, { signal: deadline?.signal });
}When the deadline passes, the RPC answers deadline_exceeded / 504.
Two details that are easy to get wrong and are handled: a unary call answers
200, not Nest's default 201 for a POST; and the global response envelope is
never applied — a Connect client reading { success: false, message } sees no
code at all and classifies every failure as unknown.
Not served: binary grpc-web. An endpoint with a codec sends
application/grpc-web+proto, which needs raw-body access and length-prefixed
trailer framing on the way out. @GrpcEndpoint() refuses it at bootstrap rather
than answering JSON to a protobuf client. Put a grpc-web proxy in front, or drop
codec and use JSON on both ends.
GraphQL — no GraphQL runtime
@tahanabavi/typefetch-graphql generates its operation document from the
endpoint's Zod response schema. Both halves of the contract therefore agree
on the selection set by construction — which means an operation can be addressed
by name and answered from the same schema that asked for it. No graphql
package, no SDL file, no resolver map.
import { Module, Injectable } from "@nestjs/common";
import { ContractInput, InferRequest } from "@tahanabavi/typewire-nestjs";
import {
ContractGraphQLModule,
GraphQLEndpoint,
} from "@tahanabavi/typewire-nestjs/graphql";
@Injectable()
export class UserResolver {
@GraphQLEndpoint(contracts.user.profile) // transport: "graphql"
profile(@ContractInput() input: InferRequest<typeof contracts.user.profile>) {
return this.users.byId(input.id); // → { data: { user: { … } } }
}
}
@Module({
imports: [ContractGraphQLModule.forRoot({ contracts })],
providers: [UserResolver],
})
export class AppModule {}A resolver is an ordinary NestJS provider. It declares no route — every
operation arrives at one endpoint (/graphql by default) which dispatches on
the operation name — and it is discovered at bootstrap, so nothing is listed
twice.
contracts is required, and not merely convenient: a GraphQL operation is
addressed by name, and the name the client sends is derived from the
endpoint's "module.endpoint" id (user.profile → UserProfile). An endpoint
object on its own does not know its own id, so the only way to reconstruct that
name is to look the object up in the map it came from.
What you get:
The result is nested under
rootwhen the contract declares one, and sits directly underdatawhen it does not — matching what the generated document selected.Guards and interceptors run. The resolver goes through NestJS's own pipeline, so
@UseGuards(PermissionGuard)enforces the contract'spermissionkey in a resolver exactly as it does on a route.@ContractInput()returns the validated variables, the same decorator an HTTP handler uses.Failures are named in GraphQL's key space —
extensions.code, which is what a GraphQL endpoint'serrorsmap is keyed by.NotFoundException→NOT_FOUND, a contract violation →BAD_USER_INPUT,ForbiddenException→FORBIDDEN; or throwGraphqlException(code, message)to say it outright.Media type negotiation. A client that accepts
application/graphql-response+jsongets the real HTTP status on a failure; one that accepts onlyapplication/jsongets the spec's200with the intended status inextensions.http.status, which is what typefetch reads.GETfor queries (?query=&variables=), so a query can be CDN-cached. Mutations are refused over GET — a mutation a cache can replay is a way to lose a write.
A contract server, not a GraphQL server
The trade is worth stating plainly. The document is not executed. The
operation is resolved by name and the response is the endpoint's response
schema — which is exactly what the client's generated selection set asked for.
That is correct for any client generated from the same contract, and it is not
a general GraphQL server: there is no introspection, no schema stitching, no
field-level resolvers, no aliases and no fragments. A hand-written query
selecting a subset gets the full contract shape back (harmless — the client's
schema strips it); one using an alias will not resolve. If you need any of that,
@nestjs/graphql is the right tool and this does not try to replace it.
WebSocket gateways (typesocket)
One contract object serves both ends. typesocket events declare their own
direction, so the client emits client->server and listens to
server->client — and a gateway does exactly the reverse, from the same file,
with no mirrored second declaration to drift.
import { WebSocketGateway, WebSocketServer } from "@nestjs/websockets";
import {
SocketEvent,
SocketPayload,
bindSocketContracts,
createSocketEmitter,
type InferSocketRequest,
} from "@tahanabavi/typewire-nestjs/socket";
import { wsContracts } from "./contracts/chat.contracts";
export const events = bindSocketContracts(wsContracts);
@WebSocketGateway()
export class ChatGateway {
@WebSocketServer() server!: Server;
private readonly emit = createSocketEmitter(() => this.server);
@SocketEvent(events.chat.sendMessage) // wire name from the contract
async send(@SocketPayload() input: InferSocketRequest<typeof events.chat.sendMessage>) {
const message = await this.chat.post(input.text);
this.emit(events.chat.message, message); // validated against `payload`
return { id: message.id }; // validated against `ack`
}
}bindSocketContracts() exists because a definition object does not know its own
name: the wire event name defaults to "module.event", which is a fact about
where it sits in the map. Binding resolves it once, the same way the client
does, and refuses two events that collide on one wire name — where a single
frame would reach both handlers and both would try to acknowledge it.
Three checks, one per direction that can break:
| Checked | Against | Why it matters |
|---|---|---|
| inbound frame | request | via @SocketPayload() — nothing else validates what a sender put on the wire |
| acknowledgement | ack | typesocket validates the ack on arrival, so a drifted one rejects far from the server that sent it |
| server push | payload | typesocket drops an inbound payload that fails its schema — a renamed field turns into a listener that silently stops firing |
createSocketPermissionGuard() enforces a client->server event's contract
permission server-side. typesocket ships a client-side equivalent and says of
it that the check is UX only — this is the real enforcement point:
export const SocketPermissionGuard = createSocketPermissionGuard({
getPermissions: (client) => client.data.perms as bigint,
authorize: P.authorize,
});
@WebSocketGateway()
@UseGuards(SocketPermissionGuard)
export class ChatGateway { /* … */ }On failures. A rejected frame throws SocketContractException, which NestJS
sends as socket.io's standard exception event; the frame is not acknowledged.
An ack is not an error channel — typesocket validates it against ack, so an
error object sent there would arrive as a malformed ack rather than as the
rejection it is. An event whose failures the caller must handle should say so in
its contract: ack: z.discriminatedUnion("ok", [ … ]), the same rule that
governs errors on an HTTP endpoint.