tspace-spear
v1.3.0
Published
tspace-spear is a lightweight, high-performance API framework for Node.js that leverages the native HTTP server and supports uWebSockets.js (C++) for maximum speed and efficiency.
Maintainers
Readme
tspace-spear
tspace-spear is a lightweight, high-performance API framework for Node.js, built on the native HTTP server with optional support for uWebSockets.js (C++) to achieve maximum speed and efficiency.
It is designed with a strong focus on developer experience and provides end-to-end (E2E) type safety and testing support across the full request lifecycle, from request input to response output (see E2E).
Features
- ⚡ High-performance core built on native Node.js HTTP
- 🚀 Optional uWebSockets.js adapter support for ultra-low latency and maximum throughput
- 🧠 End-to-end E2E type safety across the entire request → response lifecycle
- 🎮 Built-in support for Controllers and route-based architecture
- 🏷️ Powerful Decorator system for routes, middleware, validation, and metadata
- 💉 Built-in constructor-based dependency injection (DI) for Services
- 📦 DTO (Data Transfer Object) support for structured and type-safe request handling
- 📂 Built-in File Upload support via
useFileUpload()with zero configuration required - 🔌 Native WebSocket support for real-time applications and event-driven systems
- ⚛️ GraphQL support with flexible schema integration and HTTP adapters
- 🖥️ Built-in Cluster mode support for multi-core scalability and higher throughput
- 🧪 Built-in testing utilities for E2E validation
- 🧩 Simple and intuitive developer experience
- 📘 Auto-generated Swagger documentation via
app.useSwagger()with zero manual configuration - 🔥 Lightweight and optimized for high-performance APIs and microservices
Install
Install with npm:
npm install tspace-spear --save
npm install tspace-spear -gDocumentation
See the docs directory for full documentation.
Basic Usage
- Getting Started
- Quick Started
- Adapter
- Cluster
- Global Prefix
- Logger
- Format Response
- Cors
- Body
- File Upload
- Cookie
- Middleware
- Controller
- Service
- Exception
- Dto
- Router
- Serve Static Files
- Swagger
- WebSocket
- Graphql
- E2E
Getting Started
import { Spear } from "tspace-spear";
new Spear()
.get('/' , () => 'Hello world!')
.get('/json' , () => {
return {
message : 'Hello world!'
}
})
.listen(8000 , () => console.log(`Server is now listening http://localhost:8000`))Quick Started
Generate applications, modules, controllers, services, and middleware with the Spear CLI.
# Install CLI globally
npm install -g tspace-spear
# Create a new application structure
spear create new my-app
✔ Successfully created project "my-app"
📦 Project Structure
src/
├── common/
│ └── middlewares/
│ └── log.middleware.ts
│
├── modules/
│ └── cats/
│ ├── cat.controller.ts
│ ├── cat.service.ts
│ └── cat.dto.ts
│
├── client.ts
└── index.ts
🚀 Next Steps
cd my-app
npm run dev
✔ Server is running at:
http://localhost:8000
✔ Swagger Docs:
http://localhost:8000/api/docs
✔ Run E2E client:
ts-node src/client.tsAdapter
tspace-spear supports multiple server adapters, including the native Node.js HTTP server and uWebSockets.js for high performance.
⚠️ Requirements for uWebSockets.js Node.js 18 or higher is required Installation is done via GitHub (no official npm release)
import { Spear } from "tspace-spear";
import uWS from "uWebSockets.js";
// Install via package.json
// "dependencies": {
// "uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.45.0"
// }
new Spear({ adapter: uWS })
.get("/", () => "Hello world!")
.get("/json", () => {
return {
message: "Hello world!",
};
})
.listen(8000, () =>
console.log("uWS server is running at http://localhost:8000")
);Cluster
Cluster mode allows tspace-spear to run multiple worker processes to fully utilize multi-core CPU performance.
import { Spear } from "tspace-spear";
new Spear({
cluster : 3
})
.get('/' , () => 'Hello world!')
.get('/json' , () => {
return {
message : 'Hello world!'
}
})
.listen(8000 , () => console.log(`Server is now listening http://localhost:8000`))
Global Prefix
Global Prefix allows you to define a base path for all routes in your application.
It helps keep your API structured and consistent (e.g. /api, /v1, /app).
const app = new Spear({
globalPrefix : '/api', // prefix all routes
})
.get('/' , () => 'Hello world!') // http://localhost:8000/api
.get('/cats' , () => `Hello all cats`) // http://localhost:8000/api/cats
.get('/cats/:id' , ({ params }) => `Hello cat: ${params.id}`) // http://localhost:8000/api/cats/1
.listen(8000 , () => console.log(`Server is now listening http://localhost:8000`))
// http://localhost:8000/api => 'Hello world!'
// Or this
const app = new Spear()
.useGlobalPrefix('api', {
exclude : [
{
path : '/cats/*',
// methods : '*'
// methods : ['GET','POST']
}
]
})
.get('/' , () => 'Hello world!') // http://localhost:8000/api
.get('/cats' , () => `Hello all cats`) // http://localhost:8000/cats
.get('/cats/:id' , ({ params }) => `Hello cat: ${params.id}`) // http://localhost:8000/cats/1
.listen(8000 , () => console.log(`Server is now listening http://localhost:8000`))Logger
The built-in Logger provides request logging for incoming HTTP requests.
It helps you monitor:
Request method Request path Performance tracking Debugging and observability
You can enable a simple logger or configure advanced logging behavior.
const app = new Spear({
logger : true
})
// or use this for logging
.useLogger({
methods : ['GET','POST'],
exceptPath : /\/benchmark(\/|$)|\/favicon\.ico(\/|$)/ // or use Array ['/']
})
.get('/' , () => 'Hello world!')
.listen(8000 , () => console.log(`Server is now listening http://localhost:8000`))
Format Response
Provides a consistent response structure system to standardize how responses are handled across your application.
It ensures that:
- All responses are predictable
- Errors are properly structured
- Missing routes are handled cleanly
- Global error catching is supported
Notfound
The NotFound handler is triggered when no route matches the incoming request.
const app = new Spear()
.get('/' , () => {
return {
message: 'Hello world'
}
})
.notfound(({ res } : T.Context) => {
return res.notFound('Not found!')
})
.listen(8000 , () => console.log(`Server is now listening http://localhost:8000`))
// http://localhost:8000/notfound => { success: false , message : 'Not found!' , statusCode: 404 }
Response
The response system ensures that all returned values are automatically formatted and sent to the client.
You can return:
- String
- Object (JSON)
- Custom response via ctx.res
import { Spear } from "tspace-spear";
const app = new Spear()
.get('/' , () => {
return {
message: 'Hello world'
}
})
.response((results, statusCode) => {
if (typeof results === 'string') return results
if (Array.isArray(results)) {
return {
success: statusCode < 400,
data: results,
statusCode
}
}
if (typeof results === 'object' && results !== null) {
return {
success: statusCode < 400,
...results,
statusCode
}
}
return {
success: statusCode < 400,
data: results,
statusCode
}
})
.listen(8000 , () => console.log(`Server is now listening http://localhost:8000`))
// http://localhost:8000 => { success: true , message : 'Hello World' , statusCode: 200 }
Catch
The Catch handler is used to handle unexpected runtime errors globally.
It acts as a safety layer to prevent server crashes and standardize error responses.
import { Spear } from "tspace-spear";
import { z } from "zod";
const app = new Spear()
.get('/' , () => {
throw new Error('Catching failed')
})
.catch((err, { res } : T.Context) => {
if(err instanceof z.ZodError) {
return res
.status(422)
.json({
success : false,
message: "Validation failed",
issues : err?.issues,
statusCode : 422
});
}
return res
.status(500)
.json({
success : false,
message : err?.message,
statusCode : 500
});
})
.listen(8000 , () => console.log(`Server is now listening http://localhost:8000`))
// http://localhost:8000 => { success: false , message : 'Catching failed' , statusCode: 500 }
Cors
CORS (Cross-Origin Resource Sharing) controls which origins are allowed to access your API.
It helps secure your server by restricting or allowing cross-domain requests.
const app = new Spear()
.cors({
origins: [
/^http:\/\/localhost:\d+$/
],
credentials: true
})
//.cors() allow *
.listen(port , () => console.log(`Server is now allow cors localhost:* `))
Body
Body parsing allows your server to read incoming request payloads (JSON) and access them via ctx.body.
It enables handling requests with structured data.
new Spear()
// enable body payload
.useBodyParser()
.post('/' , ({ body }) => {
return {
yourBody : body
}
})
.listen(8000 , () => console.log(`Server is now listening http://localhost:8000`))
File Upload
File upload support allows handling multipart/form-data requests and working with uploaded files via ctx.files.
It provides:
- Temporary file handling
- File size limits
- Manual file movement
- Auto cleanup option
import { Spear, type T } from 'tspace-spear';
import path from 'path'
new Spear()
// use this for enable file upload
.useFileUpload({
limit : 1000 * 1000, // limit for file upload 1_000_000 bytes by default Infinity
tempFileDir : 'temp', // folder temporary directory by default tmp
removeTempFile : {
remove : true, // remove temporary files by default false
ms : 1000 * 60 // remove file temporary after 60 seconds
}
})
.post('/' , ({ files } : T.Context) => {
// you can move the file from temporary to other folder
// for example please validate the your input file
const file = files.file[0]
const folder = 'uploads'
await file.write(path.join(path.resolve(),`${folder}/${+new Date()}.${file.extension}`))
// after writed the file you should remove the temporary file
await file.remove()
return {
files
}
})
.listen(8000 , () => console.log(`Server is now listening http://localhost:8000`))
Cookie
Cookie support allows you to read and manage HTTP cookies from incoming requests via ctx.cookies.
It is useful for:
- Session handling
- Authentication
- User preferences
- Stateful requests
new Spear()
.useCookiesParser()
.post('/' , ({ cookies }) => {
return {
yourCookies : cookies
}
})
.listen(8000 , () => console.log(`Server is now listening http://localhost:8000`))Middleware
Middleware is a function that runs before the controller handler and is used to:
- Intercept requests
- Modify ctx
- Validate or block execution
- Handle authentication / logging / transformations
import { type T } from "tspace-spear"
// file cat-middleware.ts
export default (ctx : T.Context, next: T.NextFunction) =>{
console.log('cat middleware globals');
return next();
}
import Spear { Router, type T } from "tspace-spear";
import CatMiddleware from './cat-middleware.ts'
(async () => {
const port = Number(process.env.PORT ?? 8000)
const app = new Spear({
middlewares: [ CatMiddleware ]
// if you want to import middlewares with a directory can you follow the example
// middlewares : {
// folder : `${__dirname}/middlewares`,
// name : /middleware\.(ts|js)$/i
// }
})
// or add a middleware
app.use((ctx : T.Context , next : T.NextFunction) => {
console.log('global middlewares')
return next()
})
app.get('/' ((ctx,next) => {
console.log('middleware on the crrent route')
return next()
}), ({ res } : T.Context) => {
return res.json({
message : 'hello world!'
});
})
app.get('/' , ({ res } : T.Context) => {
return res.json({
message : 'hello world!'
});
})
app.listen(port , () => console.log(`Server is now listening http://localhost:8000`))
// localhost:8000
})()Controller
A Controller is used to group related routes and define request handlers in a structured way.
It helps organize your application into modules (similar to NestJS / Express routers), while keeping a clean and readable API design.
import {
Controller ,
Middleware ,
Get ,
Post,
Patch,
Put,
Delete,
WriteHeader,
Query,
Body,
Params,
Cookies,
Files,
StatusCode,
type T
} from 'tspace-spear';
import CatMiddleware from './cat-middleware.ts'
// file cat-controller.ts
@Controller('/cats')
class CatController {
@Get('/')
@Middleware(CatMiddleware)
@Query('test','id')
@Cookies('name')
public async index({ query , cookies } : {
query : T.Query<{ id : string }>
cookies : T.Cookies<{ name : string}>
}) {
return {
query,
cookies
}
}
@Get('/:id')
@Middleware(CatMiddleware)
@Params('id')
public async show({ params } : T.Context) {
return {
params
}
}
@Post('/')
@Middleware(CatMiddleware)
public async store({ body } : T.Context) {
return {
body
}
}
@Put('/:id')
@Middleware(CatMiddleware)
public async update({ files } : T.Context) {
return {
files
}
}
@Post('/upload')
@Middleware(CatMiddleware)
public async upload({ files } : T.Context) {
return {
files
}
}
@Delete('/:id')
@Middleware(CatMiddleware)
public async destroy({ params } : T.Context) {
return {
params
}
}
}
import { Spear } , { Router, type T } from "tspace-spear";
import CatController from './cat-controller.ts'
(async () => {
const app = new Spear({
controllers: [ CatController ]
// if you want to import controllers with a directory can you follow the example
// controllers : {
// folder : `${__dirname}/controllers`, // nestjs style `${__dirname}/modules/*`
// name : /controller\.(ts|js)$/i,
// *Auto-generate route metadata for type-safe E2E usage,
// *and swagger documentation. By default if use .useSwagger() in app no need to set any description
// preRouteTypes : true
// }
})
app.useBodyParser()
app.useCookiesParser()
app.useFileUpload()
app.get('/' , ( { res } : T.Context) => {
return res.json({
message : 'hello world!'
});
})
app.listen(8000 , () => console.log(`Server is now listening http://localhost:8000`))
// localhost:8000/cats
// localhost:8000/cats/41
})()Service
Registers one or more service classes for Dependency Injection.
All registered services will be available in the controller constructor without manual instantiation.
// cat-service.ts
class CatService {
public index () {
return [
{
id: 1,
name: 'cat1'
},
{
id: 2,
name: 'cat2'
}
]
}
}
// cat-controller.ts
import {
Controller,
Get
} from 'tspace-spear';
import CatService from './cat-service.ts'
@Service([CatService]) // don't forgot this to send CatService for Dependency Injection(DI)
@Controller('/cats')
class CatController {
constructor(
private catService: CatService
) {}
@Get('/')
public index() {
return this.catService.index();
}
}
// app.ts
import { Spear } from "tspace-spear";
(async () => {
const app = new Spear({
controllers : {
folder : `${__dirname}/controllers`,
name : /controller\.(ts|js)$/i,
preRouteTypes : true
}
});
app.useSwagger();
app.listen(8000 , () => console.log(`Server is now listening http://localhost:8000`));
})()Exception
Exceptions are used to return HTTP errors from your application.
import {
BadRequestException,
UnauthorizedException,
ForbiddenException,
NotFoundException,
MethodNotAllowedException,
ConflictException,
GoneException,
UnsupportedMediaTypeException,
UnprocessableEntityException,
TooManyRequestsException,
InternalServerErrorException,
NotImplementedException,
BadGatewayException,
ServiceUnavailableException,
GatewayTimeoutException,
} from 'tspace-spear/exception';
@Controller('/users')
class UserController {
@Get('/:id')
public async show({ params }) {
if (!params.id) {
throw new BadRequestException('User id is required');
}
return {
id: params.id,
};
}
}Exception
Exceptions are used to return HTTP errors from your application.
import {
BadRequestException,
UnauthorizedException,
ForbiddenException,
NotFoundException,
MethodNotAllowedException,
ConflictException,
GoneException,
UnsupportedMediaTypeException,
UnprocessableEntityException,
TooManyRequestsException,
InternalServerErrorException,
NotImplementedException,
BadGatewayException,
ServiceUnavailableException,
GatewayTimeoutException,
} from 'tspace-spear/exception';
@Controller('/users')
class UserController {
@Get('/:id')
public async show({ params }) {
if (!params.id) {
throw new BadRequestException('User id is required');
}
return {
id: params.id,
};
}
}
Dto
DTO (Data Transfer Object) is used to validate and transform incoming request data before it reaches your controller logic.
import {
Controller ,
Post,
Validate,
ValidateDto,
createDtoDecorator,
type T
} from 'tspace-spear';
import z from "zod";
import {
IsString,
IsInt,
validate
} from "class-validator";
const ValidateDtoCustomBody = (keys: string[]) => {
return createDtoDecorator((ctx) => {
const body = ctx.body ?? {};
const issues: Array<{ path: string; message: string }> = [];
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (body[key] == null) {
issues.push({
path: key,
message: "Missing field",
});
}
}
if (issues.length > 0) {
throw {
message : "Validation failed",
issues
}
}
});
}
const ValidateDtoPromiseBody = (keys: string[]) => {
return createDtoDecorator(async (ctx) => {
await new Promise(resolve => setTimeout(resolve,500));
// check in DB or other async operation
const cats = await catRepository.findMany({ where : { name : ctx.body.name }});
if(!cats.length) {
throw new Error('Validation failed in promise!');
}
}, (ctx, error) => {
// you implement your custom error handling for async validation here
return ctx.res.status(400).json({
message: error.message || "Validation failed",
issues: error.issues || [],
});
});
}
const catSchema = z.object({
name: z.string(),
age: z.number(),
})
class CreateCatDto {
@IsString()
name!: string;
@IsInt()
age!: number;
}
// file cat-controller.ts
@Controller('/cats')
export class CatController {
@Post('/')
// only required validation without type checking
@Validate(["name", "age"], { required: { allowEmptyString: false, allowNull: false } })
public async basic(ctx : T.Context<{ body : { name : any , age : any }}>) {
return {
body : ctx.body
}
}
@Post('/custom')
@ValidateDtoCustomBody(["name", "age"])
public async custom(ctx : T.Context<{ body : { name : string , age : number }}>) {
return {
body : ctx.body
}
}
@Post('/promise')
@ValidateDtoPromiseBody(['name'])
public async promise(ctx : T.Context<{ body : { name : string }}>) {
return {
body : ctx.body
}
}
@Post('/zod')
@ValidateDto(catSchema, { adaptor : "zod" })
public async zod(ctx : T.Context<{ body : z.infer<typeof catSchema>}>) {
return {
body : ctx.body
}
}
@Post('/cls')
@ValidateDto(CreateCatDto)
public async cls(ctx : T.Context<{ body : CreateCatDto }>) {
return {
body : ctx.body
}
}
}
import { Spear } , { Router, type T } from "tspace-spear";
import CatController from './cat-controller.ts'
(async () => {
new Spear({
controllers: [ CatController ]
})
.useBodyParser()
.listen(8000 , () => console.log(`Server is now listening http://localhost:8000`))
// localhost:8000/cats // basic implete
// localhost:8000/cats/zod // zod implete
// localhost:8000/cats/promise // promise implete
})()Router
The Router allows you to organize routes into modular groups, making your application more scalable and maintainable.
It supports:
- Grouped routes
- Nested route prefixes
- Reusable router modules
- Separation of concerns
import { Spear, Router, type T } from "tspace-spear";
const app = new Spear()
const router = new Router()
router.groups('/my',(r) => {
r.get('/cats' , ({ req , res }) => {
return res.json({
message : 'Hello, World!'
})
})
return r
})
router.get('/cats' , ({ req , res }) => {
return res.json({
message : 'Hello, World!'
})
})
app.useRouter(router)
app.get('/' , ({ res } : T.Context) => {
return res.json({
message : 'hello world!'
});
})
let port = 8000
app.listen(port , () => console.log(`Server is now listening http://localhost:8000`))
// localhost:8000/my/cats
// localhost:8000/cats
Serve Static Files
Serve files directly from a local directory.
const path = require('path');
new Spear({
logger: true
})
.get('/file/*', (ctx) => {
// Maps:
// /file/example.pdf
// -> ./example/example.pdf
const filePath = path.join(
path.resolve(),
'example',
String(ctx.params['*'])
);
return ctx.res.serveMedia(filePath);
})
.listen(3000 , ({ port }) => {
console.log(`server listening on : http://localhost:${port}`)
})
Swagger
Provides built-in Swagger support to document your API endpoints.
It allows you to:
- Describe request parameters (query, body, params)
- Generate API documentation
- Improve developer experience
- Standardize API contracts
// file cat-controller.ts
import {
type T,
Controller,
Get ,
Post,
Put,
Patch,
Delete,
Swagger
} from 'tspace-spear';
@Controller('/cats')
class CatController {
@Get('/')
@Swagger({
query : {
id : {
type : 'integer'
},
name : {
type : 'string'
}
}
})
public async index({ query } : T.Context) {
return {
query
}
}
@Get('/:id')
@Swagger({
description : '- message',
query : {
id : {
type : 'integer'
}
},
responses : [
{ status : 200 , description : "OK" , example : { id : 'catz' }},
{ status : 400 , description : "Bad request" , example : { id : 'catz' }}
]
})
public async show({ params } : T.Context) {
return {
params
}
}
@Post('/')
@Swagger({
bearerToken : true,
body : {
description : 'The description !',
required : true,
properties : {
id : {
type : 'integer',
example : 1
},
name : {
type : 'string',
example : "xxxxx"
},
status : {
type : 'string',
example: "active",
enum: ['active', 'inactive'],
description: "User status (active = enabled, inactive = disabled)",
required : true
},
roles: {
type: 'array',
items : {
type: 'string',
example: "roleA",
enum: ['roleA', 'roleB']
}
},
}
}
})
public async store({ body } : T.Context) {
return {
body
}
}
@Put('/:uuid')
@Swagger({
bearerToken : true,
body : {
description : 'The description !',
required : true,
properties : {
id : {
type : 'integer',
example : 1
},
name : {
type : 'string',
example : "xxxxx"
}
}
}
})
public async updated({ body } : T.Context) {
return {
body
}
}
@Patch('/:uuid')
@Swagger({
bearerToken : true,
body : {
description : 'The description !',
required : true,
properties : {
id : {
type : 'integer',
example : 1
},
name : {
type : 'string',
example : "xxxxx"
}
}
}
})
public async update({ body } : T.Context) {
return {
body
}
}
@Delete('/:uuid')
@Swagger({
bearerToken : true
})
public async delete({ params } : T.Context) {
return {
params
}
}
@Post('/upload')
@Swagger({
bearerToken : true,
files : {
required : true,
properties : {
file : {
type : 'array',
items: {
type :"string",
format:"binary"
}
},
name : {
type : 'string'
}
}
}
})
public async upload({ body , files } : T.Context) {
return {
body,
files
}
}
}
(async () => {
await new Spear({
controllers: [ CatController ]
})
.get('/' , ({ res } : T.Context) => {
return res.json({
message : 'hello world!'
});
})
// .useSwagger() // by default path is "/api/docs"
.useSwagger({
path : "/docs",
servers : [
{ url : "http://localhost:8000" , description : "development"},
{ url : "http://localhost:8000" , description : "production"}
],
info : {
"title" : "Welcome to the the documentation",
"description" : "This is the documentation"
}
})
.listen(8000 , () => console.log(`Server is now listening http://localhost:8000`))
// localhost:8000/docs
})()
WebSocket
Provides built-in WebSocket support for real-time communication.
It allows you to:
- Handle client connections
- Send/receive messages
- Build chat systems
- Manage real-time events
import { Spear } from "tspace-spear";
import fs from 'fs';
import path from 'path';
new Spear()
.get('/',(ctx) => {
// you can serve a HTML file for testing WebSocket connection
const htmlWs = fs.readFileSync(path.join(path.resolve(), 'public', 'ws.html'), 'utf-8');
return ctx.res.html(htmlWs);
})
.ws(() => {
const clients = new Map<string, any>();
return {
connection: (ws) => {
console.log("connected");
},
message: (ws, msg) => {
const data = JSON.parse(msg.toString());
if (data.type === "register") {
ws.userId = data.userId;
clients.set(data.userId, ws);
ws.send(JSON.stringify({
type: "system",
message: `registered as ${data.userId}`
}));
return;
}
if (data.type === "chat") {
const targetWs = clients.get(data.to);
if (!targetWs) {
ws.send(JSON.stringify({
type: "error",
message: "User not online"
}));
return;
}
targetWs.send(JSON.stringify({
type: "chat",
from: ws.userId,
text: data.text
}));
return;
}
},
close: (ws) => {
if (ws.userId) {
clients.delete(ws.userId);
}
},
error: (ws, error) => {
console.error('WebSocket error:', error);
}
};
})
.listen(8000 , ({ port , server }) => {
console.log(`server listening on : http://localhost:${port}`)
})
Graphql
GraphQL CRUD Example with graphql-yoga + tspace-spear
This example shows how to build a simple GraphQL CRUD API using graphql-yoga and tspace-spear.
It includes:
- GraphQL schema setup
- Query and Mutation examples
- Create / Read / Update / Delete operations
- HTTP integration with graphql-yoga
The server uses an in-memory array as a fake database for simplicity.
Features
- High performance HTTP server with tspace-spear
- Native GraphQL schema definitions
- Full CRUD operations
- Simple and dependency-light setup
- Works with standard GraphQL clients and tools
npm install graphql graphql-yogaimport { type T, Spear } from "tspace-spear";
import {
createYoga,
createSchema,
} from 'graphql-yoga';
type User = {
id: number;
name: string;
email: string;
};
const users: User[] = [
{
id: 1,
name: 'John',
email: '[email protected]',
},
];
const yoga = createYoga({
graphqlEndpoint: '/graphql',
schema: createSchema({
typeDefs: /* GraphQL */ `
type User {
id: Int!
name: String!
email: String!
}
input CreateUserInput {
name: String!
email: String!
}
input UpdateUserInput {
id: Int!
name: String
email: String
}
type Query {
users: [User!]!
user(id: Int!): User
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(input: UpdateUserInput!): User!
removeUser(id: Int!): User!
}
`,
resolvers: {
Query: {
users: () => users,
user: (_root, args) => {
return users.find(
(u) => u.id === args.id,
);
},
},
Mutation: {
createUser: (_root, args) => {
const user = {
id: Date.now(),
name: args.input.name,
email: args.input.email,
};
users.push(user);
return user;
},
updateUser: (_root, args) => {
const user = users.find(
(u) => u.id === args.input.id,
);
if (!user) {
throw new Error(
'User not found',
);
}
if (args.input.name !== undefined) {
user.name = args.input.name;
}
if (args.input.email !== undefined) {
user.email = args.input.email;
}
return user;
},
removeUser: (_root, args) => {
const index = users.findIndex(
(u) => u.id === args.id,
);
if (index === -1) {
throw new Error(
'User not found',
);
}
const deleted = users[index];
users.splice(index, 1);
return deleted;
},
},
},
}),
});
const app = new Spear()
.get('/graphql',({ req , res }) => yoga(req , res))
.post('/graphql',({ req , res }) => yoga(req , res))
.listen(4000 , ({ port , server }) => {
console.log(`server listening on : http://localhost:${port}/graphql`)
})E2E
Provides end-to-end type safety and testing support across the full request lifecycle, from request input to response output. It allows you to:
// file cat-controller.ts
import z from 'zod';
import {
type T,
Controller,
Get,
Post,
Put,
Delete,
createDtoDecorator
} from "tspace-spear";
const catSchema = z.object({
id: z.number(),
name: z.string(),
age: z.number(),
});
const catSchemaAction = z.object({
name: z.string(),
age: z.number(),
});
type Cat = z.infer<typeof catSchema>
let cats: z.infer<typeof catSchema>[] = [
{ id: 1, name: 'cat1', age: 1.6 },
{ id: 2, name: 'cat2', age: 1.8 },
];
const ValidateDtoBody = (schema: z.ZodTypeAny) => {
return createDtoDecorator((ctx) => {
const result = schema.parse(ctx.body);
ctx.body = result as T.Body;
});
};
@Controller('/cats')
class CatController {
@Get('/')
public async index({
query,
}: T.Context) {
return {
cats,
};
}
@Get('/:id')
public async show({ res, params }: T.Context<{ params: { id: number } }>) : Promise<{
cat : Cat
}> {
const cat = cats.find((d) => d.id === Number(params.id));
if(cat == null) {
throw res.notFound('not found cat')
}
return {
cat
};
}
@Post('/')
@ValidateDtoBody(catSchemaAction)
public async create({
body,
}: T.Context<{ body: z.infer<typeof catSchemaAction> }>) {
const cat = {
id: cats.length + 1,
...body
}
cats.push(cat);
return {
cat,
message: 'created',
};
}
@Put('/:id')
@ValidateDtoBody(catSchemaAction.partial())
public async update({
res,
params,
body,
}: T.Context<{
params: { id: number };
body: Partial<z.infer<typeof catSchemaAction>>;
}>) {
const id = Number(params.id);
const index = cats.findIndex((d) => d.id === id);
if (index === -1) {
throw res.notFound('not found cat')
}
cats[index] = {
...cats[index],
...body,
id
};
const cat = cats[index]
return {
message: 'updated',
cat,
};
}
@Delete('/:id')
public async remove({ res, params }: T.Context<{ params: { id: number } }>) {
const id = Number(params.id);
const index = cats.findIndex((d) => d.id === id);
if (index === -1) {
throw res.notFound('not found cat')
}
cats = cats.filter((d) => d.id !== id);
return {
message: 'deleted',
};
}
}
export { CatController };
export default CatController;
// file server/app.ts
import Spear from "tspace-spear";
const app = new Spear({
logger : true,
controllers: {
folder : `${__dirname}/controllers/*`,
name:/controller\.(ts|js)$/i,
// don't forget to set this option for auto-generate route metadata for type-safe E2E usage,
// and swagger documentation. By default if use .useSwagger() in app no need to set any description
preRouteTypes: true
}
})
app.useGlobalPrefix('api');
app.useBodyParser();
app.listen(8000 , () => console.log(`Server is now listening http://localhost:8000`));
type AppRouter = typeof app.contract;
export { AppRouter }
export default app;
// file frontend/index.ts
import { AppRouter } from "./server/app";
import { ApiClient } from "tspace-spear/client";
const client: ApiClient<AppRouter> = new ApiClient(
`http://localhost:8000/api`
);
await client.get("/catsq"); ❌ // Type error: Argument of type '"/catsq"' is not assignable to parameter of type '"/cats" | "/cats/:id" | ... 3 more
const res = await client.get("/cats");
// res.ok -> true or false
// res.status -> number
// res.headers -> Hearders
// res.data -> { cats: [{ id: 1, name: 'cat1', age: 1.6 },{ id: 2, name: 'cat2', age: 1.8 }] }
// Without checking `res.ok`, `res.data` is always typed as `any`.
if(res.ok) {
res.data.cats = 1 ❌ // Type error: Type 'number' is not assignable to type '{ id: number; name: string; age: number; }[]'
res.data.cats[0].name = 1 ❌ // Type error: Type 'number' is not assignable to type 'string'
res.data.cats[0].age = "1.6" ❌ // Type error: Type 'string' is not assignable to type 'number'
}
await client.get("/cats/:id") ❌ // Expected 2 arguments, but got 1.
await client.get("/cats/:id", { params : { id : "1" }}) ❌
// The expected type comes from property 'id' which is declared here on type '{ id: number; }'
await client.get("/cats/:id", { params : { id : 1 }}) ✅
await client.post("/cats") ❌ // Expected 2 arguments, but got 1.
await client.post("/cats",{
body : {} ❌ // Type '{}' is missing the following properties from type '{ name: string; age: number; }'
});
await client.post("/cats",{
body : { name : "super cat" } ❌
// Property 'age' is missing in type '{ name: string; }'
// but required in type '{ name: string; age: number; }'.
});
await client.post("/cats",{
body : { name : "super cat" , age : 5 } ✅
});
await client.put("/cats") ✅
await client.put("/cats",{
body : {} ✅
});
await client.put("/cats",{
body : { name : 1 } ❌ // Type 'number' is not assignable to type 'string'.
});
await client.put("/cats",{
body : { name : "super cat" } ✅
});
await client.put("/cats",{
body : { name : "super cat" , age : 5 } ✅
});
