@sx3/ultra
v0.1.3
Published
Type-safe RPC over HTTP/WebSocket for Bun
Maintainers
Readme
Ultra
Type-safe and fast RPC over HTTP/WebSocket for Bun.
Table of contents
Install
bun add @sx3/ultraQuick start
Write a simple server with two modules: users and books.
// server.ts
import { Ultra } from '@sx3/ultra';
// User module
const users = new Ultra().routes(input => ({
users: {
list: input().http().handler(() => [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
])
}
}));
// Book module
const books = new Ultra().routes(input => ({
books: {
list: input().http().handler(() => [
{ id: 1, title: 'TypeScript' },
{ id: 2, title: 'Brave New World' }
]),
}
}));
// Root module
const server = new Ultra()
.use(users)
.use(books)
.on('server:started', (bunServer) => {
console.log(`Server started at ${bunServer.url}`);
})
.start();
// Type for client usage
export type Server = typeof server;Create a client to call the server methods.
// clients.ts
import type { Server } from './server';
import { createHTTPClient } from '@sx3/ultra/client';
const http = createHTTPClient<Server>({
baseUrl: 'http://localhost:3000',
});
const users = await http.users.list(); // [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]
const books = await http.books.list(); // [{ id: 1, title: 'TypeScript' }, ...]Create WebSocket client to call server methods over WebSocket.
// clients.ts
import type { Server } from './server';
import { createWebSocketClient } from '@sx3/ultra/client';
let socket = new WebSocket('ws://localhost:3000/ws');
const ws = createWebSocketClient<Server>({
// Socket getter function
socket: () => socket,
});
const users = await ws.users.list(); // [{ id: 1, name: 'Alice' } ...]Or create super client for dynamic transport switching.
Middleware
Middleware just functions that run before your route handlers. You can use them to add authentication, logging, error handling, etc.
import { Ultra } from '@sx3/ultra';
import { UnauthorizedError } from '@sx3/ultra/error';
// Simple authentication middleware
async function isAuthenticated({ context, next }) {
if (!await context.auth.check()) return new UnauthorizedError();
return next();
}
const app = new Ultra()
.use(isAuthenticated) // Apply middleware globally
.routes(input => ({
profile: {
get: input()
.use(isAuthenticated) // Apply middleware to specific route
.http()
.handler(({ context }) => {
return context.auth.user; // Access authenticated user
}),
},
}), [isAuthenticated]); // Apply middleware scoped to routesValidation
Ultra supports any library compatible with Standard Schema.
import { Ultra } from '@sx3/ultra';
import { createHTTPClient } from '@sx3/ultra/client';
// import * as z from 'zod';
import * as s from 'sury';
const LoginSchema = s.schema({
name: s.string(),
password: s.string(),
});
const UserSchema = s.schema({
id: s.number(),
name: s.string(),
});
const auth = new Ultra().routes(input => ({
auth: {
// Schema for runtime input validation
login: input(LoginSchema)
// Schema for runtime output validation
.output(UserSchema)
.http()
.handler(({ input }) => {
// input is typed and validated as { name: string; password: string }
const user = { id: 1, name: input.name };
return user;
}),
}
}));
const client = createHTTPClient<typeof auth>({
baseUrl: 'http://localhost:3000',
});
const user = await client.auth.login({ name: 'Alice', password: 'secret' }); // user is typed as { id: number; name: string }Difference between runtime validation and TypeScript types:
import * as z from 'zod';
const api = new Ultra().routes(input => ({
// Ultra checks input and output data
validated: input(z.object({ a: z.number(), b: z.number() }))
.output(z.number())
.http()
.handler(({ input }) => {
// input is typed and validated as { a: number; b: number }
return input.a + input.b;
}),
// You are confident in your types and don't want to waste CPU/memory on validation.
typed: input<{ a: number; b: number }>()
.output<number>()
.http()
.handler(({ input }) => {
// input is typed as { a: number; b: number } but NOT validated
return input.a + input.b;
}),
}));Context
Ultra provides context system that allows you to share data across your application. You can extend the context with a function or value. Example from session module:
// session.ts
export function createSessionModule<S extends Record<string, SessionStoreFactory>>(config: SessionConfig<S>) {
return new Ultra()
// Every socket connection
.deriveUpgrade((context) => {
const id = Session.getOrCreateId((context as HTTPContext).request, config);
return {
headers: { 'Set-Cookie': new Cookie(config.name, sign(id, config.secret), config.cookie).toString() },
data: { sessionId: id },
};
})
// Every request
.derive(context => ({ session: new Session(config, context) }))
.use(async ({ context, next }) => {
if (isWS(context)) {
// context.ws.data.sessionId typed by string
}
await context.session.initiate(); // Fully typed
const response = await next();
await context.session.commit();
return response;
});
}You can add a static value for each request:
const app = new Ultra().derive({ appName: 'My Ultra App' });Context can be extended by other modules:
const app = new Ultra()
.use(session) // session module adds `session` to context
.use(auth) // auth module adds `auth` to context
.use(({ context: { auth, session }, next }) => {
// auth and session fully typed
return next();
});Core concepts
Modules
Each module is a self-contained application:
// auth.ts
// This is a self-contained application. It declares all its dependencies.
const auth = new Ultra()
.use(cors) // Use CORS middleware
.use(session) // Use session module
.routes(input => ({
auth: {
login: input(s.schema({ login: s.string(), password: s.string() }))
.http()
.handler(({ input }) => {
// Handle login
}),
}
}));
// You can run it independently
auth.start();
// Or use it as a module in another application
const main = new Ultra()
.use(auth)
.start();You can use modules as many times as you like:
const moduleA = new Ultra();
const moduleB = new Ultra()
.use(moduleA); // Use first time
const moduleC = new Ultra()
.use(moduleB)
.use(moduleA); // Use second time
const mainApp = new Ultra()
.use(moduleA)
.use(moduleB)
.use(moduleC)
.start();It may seem like modules will be duplicated and cause conflicts, but Ultra, under the hood, deduplicates everything that is connected to it.
This applies not only to modules:
// Derive function
function requestIdDerive(context) {
console.log('Deriving!');
return { requestId: crypto.randomUUID() };
}
// Middleware
async function logger({ next }) {
console.log('Request!');
return next();
}
// Routes
function routes(input) {
return {
ping: input().http().handler(() => {
console.log('Handling ping!');
return 'pong';
}),
};
}
const a = new Ultra()
.derive(requestIdDerive)
.use(logger)
.routes(routes);
const b = new Ultra()
.derive(requestIdDerive)
.use(logger)
.routes(routes);
const app = new Ultra()
.derive(requestIdDerive)
.use(logger)
.use(a)
.use(b)
.routes(routes)
.start();
fetch('http://localhost:3000/ping'); // Printed: Deriving!, Request!, Handling ping!Type safety
Ultra provides end-to-end type safety for your server and clients.
import { Ultra } from '@sx3/ultra';
import { createSuperClient } from '@sx3/ultra/client';
const math = new Ultra().routes(input => ({
math: {
add: input<{ a: number; b: number }>()
.http()
.handler(({ input }) => {
// input is typed as { a: number; b: number }
return input.a + input.b;
}),
}
}));
const client = createSuperClient<typeof math>({/** ... */});
const result = await client.math.add({ a: 1, b: 2 }); // the result is automatically inferred as a numberProtocol independence
Ultra strives to be independent of specific protocols. You simply call functions and the application decides how to send the data.
// clients.ts
import type { Server } from './server';
import { createHTTPClient, createSuperClient, createWebSocketClient } from '@sx3/ultra/client';
const http = createHTTPClient<Server>({
baseUrl: 'http://localhost:3000',
});
let socket = new WebSocket('ws://localhost:3000/ws');
const ws = createWebSocketClient<Server>({
socket: () => socket,
});
const api = createSuperClient<Server>({
// Transport picker function | if WebSocket is open, use it; otherwise, use HTTP
pick: (method: string, params: unknown, options?: any) => {
if (socket.readyState === WebSocket.OPEN) return ws;
return http;
}
});
const users = await api.users.list(); // [{ id: 1, name: 'Alice' } ...]Currently only HTTP and WebSockets are supported.
Built-in features
Ultra has several built-in features to make your life easier.
CORS
import { Ultra } from '@sx3/ultra';
import { createCORSMiddleware } from '@sx3/ultra/cors';
const cors = createCORSMiddleware({
origin: ['http://localhost:5173'],
credentials: true,
// methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
// allowedHeaders: ['Content-Type', 'Authorization'],
// exposedHeaders: ['X-Custom-Header'],
// maxAge: 3600,
});
const app = new Ultra().use(cors); // Apply CORS middleware globallySessions
Multiple session stores are supported: in-memory, Redis, and your custom stores.
// session.ts
import { env } from '#app/env';
import { createSessionModule, defineConfig, MemorySessionStore, RedisSessionStore } from '@/sx3/ultra/session';
export const config = defineConfig({
// Name for cookie or prefix for redis key and session cookie
name: 'session',
ttl: 3600, // 1 hour
store: 'redis',
secret: env.APP_KEY,
cookie: {
path: '/',
httpOnly: true,
secure: env.NODE_ENV === 'production',
sameSite: 'lax',
},
stores: {
redis: config => new RedisSessionStore(config, redis),
memory: config => new MemorySessionStore(config),
},
});
export const session = createSessionModule(config);
// server.ts
import { Ultra } from '@sx3/ultra';
import { session } from './session';
const app = new Ultra().use(session).routes(input => ({
profile: {
get: input().http().handler(({ context: { session } }) => {
// Access session data
session.get('user');
session.set('user', { id: 1, name: 'Alice' });
}),
},
})).start();Authentication
// auth.ts
import { Ultra } from '@sx3/ultra';
import { createAuthModule, defineConfig, SessionAuthProvider } from '@sx3/ultra/auth';
import type { SessionContext } from '@sx3/ultra/session';
interface User {
name: string;
age: number;
}
const config = defineConfig<User>({
provider: 'session',
providers: {
session: context => new SessionAuthProvider<User>(context as SessionContext),
},
});
export const auth = createAuthModule<User>(config);
// server.ts
import { auth } from './auth';
import { session } from './session';
import { isAuthenticated, isGuest } from '@sx3/ultra/auth';
const app = new Ultra()
.use(session)
.use(auth)
.routes(input => ({
// Just example
auth: {
login: input(LoginSchema)
.output(UserSchema)
.http()
.use(isGuest)
.handler(async ({ input, context }) => {
// ... check credentials logic
// then
await context.auth.login(user);
return user;
}),
logout: input()
.http()
.use(isAuthenticated)
.handler(({ context }) => context.auth.logout()),
profile: input().use(isAuthenticated).http().handler(({ context }) => context.auth.user!),
}
}))
.start();Crypto
Crypto functions are available here.
