TL;DR:
I love using serverless platforms — but I don’t always want to rely on them. Vendor lock-in, cold starts, and the occasional subpar local development experience make it less enticing. Server-based solutions have the downside of constant compute, which can end up costing a bit, both for indie projects and enterprises alike. Enter Vramework, a TypeScript-first solution that unifies serverless-friendly concepts with server-friendly deployment. It can handle HTTP, WebSocket, and CRON tasks all via plain functions, meaning the decision of how to deploy things can be made and changed at any point during your project’s lifecycle, without costly rewrites or vendor lock-ins.
In this post, I’ll share why I created Vramework, how it addresses common challenges, and a quick code example that shows how to define an HTTP route. I’d love to hear your thoughts and feedback!
The Backstory: Serverless vs. “Traditional” Approaches
Personal Projects
When spinning up my own side projects, I wanted:
- Serverless convenience (so I’m not paying for idle compute).
- A simple offline developer experience (just an Express server that restarts on change, please).
Most frameworks gave me friction:
- Serverless-focused frameworks rely on offline simulators; some of them are really not that great.
- Something like NestJS or Express required a lot of bundling to get working on serverless, often with bigger coldstarts and bundle sizes.
Cofounding Experience
I was the CTO and co-founder of deepstreamHub, a hosted version of the deepstream.io server. While I don't think hosting deepstream on serverless would have been a clear decision (due to latency being one of the main selling points), I think it would have been a really great underlying technology for the open-source project itself. Being able to give clients the opportunity to select their favorite deployment style, as well as really simplifying some of the server’s design architecture, would have been great.
Client Deployments
I’ve worked with clients who juggle multiple clouds and on-prem Kubernetes. They don’t want to rewrite the same logic for each environment. So the question became: How do we unify these deployments without endless boilerplate or duplicating code?
That’s what Vramework aims to solve. It’s small enough to stay out of your way, but structured enough to keep your code organized across any environment—serverless, Docker, or multi-cloud.
Cost Saving
A couple of clients also paid more for their QA environments than production, due to how many of their temporary environments were idling with relatively big CPUs. Being able to make a switch from server to serverless without a large rewrite could save tens of thousands of dollars with very low risk.
Why Function-First?
Vramework is built around plain TypeScript functions. Rather than using decorators and classes, you have functions (the equivalent of Lambdas) and you hook them up via addRoute
.
// createTodo.function.ts
import { APIFunction } from './vramework/types';
export const createTodo: APIFunction<{ task: string }, Todo> = async (
services,
data,
userSession
) => {
return await services.createTodo({ task, createdBy: userSession.userId });
};
Then, you register it as an HTTP route:
import { addRoute } from '@vramework/core';
import { createTodo } from './createTodo.function.js';
addRoute({
// Method Type
method: 'post',
// Method Route, supports params
route: '/todo',
// The function to call, this will call the function
// with the body/query and params squashed into one object
// and verified against the input type
func: createTodo,
// Optionally attach permissions, which are or'd and run in parallel until at least one passes.
permissions: {
isAdmin,
isTodoCreator: [isTodoCreated, isBelowRateLimit],
},
// Extra meta used for openAPI; the route, method, and types are extracted from the function and meta
docs: {
description: 'Create a todo',
tags: ['todo'],
},
});
Why does this matter?
- Session Management is built in; each function can access the current user session if available.
- Type Safety: Vramework generates schemas from TypeScript; if the API is called with anything else, the function will never be called.
- Permission: If permissions are provided, they will be checked before calling the function.
- Services: The function is provided with all the services, meaning they are initialized correctly in advance. It can also optionally create services for each request if needed.
- Deployment Flexibility: The same function runs locally (e.g., in Docker) or on AWS Lambda, Cloudflare Workers, etc.
Running on Next.js
You can call Vramework routes inside Next.js without leaving your process via a small wrapper. This is just an example of how we can depend on any HTTP server/runtime.
async function addTodo(text: string) {
'use server'
await vramework().actionRequest('/todo', 'post', { text })
}
async function toggleTodo(todoId: string, completedAt: Date | null) {
'use server'
await vramework().actionRequest('/todo/:todoId', 'patch', {
todoId,
completedAt,
})
}
export default async function TodoPage() {
const todos = await vramework().staticActionRequest('/todos', 'get', null)
return <TodosCard todos={todos} addTodo={addTodo} toggleTodo={toggleTodo} />
}
This eliminates extra HTTP overhead between your front end and back end, while still allowing your backend to be separate from your Next.js API if needed (for example, for backend tests or using it in other projects).
- Vercel Edge? We haven’t tested it thoroughly, but Vramework-based middleware can deploy on the edge to speed up response times. Let us know!
Services & Bundle Size: The Trade-off
Currently, Vramework creates singleton services upfront—like database connections or loggers—and shares them with each function. This:
- Reduces complexity (no manual DI).
- Runs more efficiently than bundling an entire Express or NestJS server on serverless.
But it can inflate bundle size, because you’re pulling in all services whether you need them or not. My “workspace-starter” with HTTP + WebSockets is ~250kb (mostly due to Postgres + AJV). I’m working on service injection for a future release, so only the required services get bundled. This will mean you can tell Vramework exactly which functions/routes you want (either by tags or otherwise), and it will only create the services needed for those functions to work.
Is this a fair trade-off? Let me know how you feel about balancing simpler service management vs. a slightly bigger bundle.
WebSockets on Serverless? Yup!
Yes, Vramework even handles WebSocket connections on serverless. This is huge for real-time apps. For example:
import type {
ChannelConnection,
ChannelDisconnection,
ChannelMessage,
} from './vramework/vramework-types.js'
export const onConnect: ChannelConnection<'hello!'> = async (
services,
channel
) => {
services.logger.info(
`Connected to event channel with opening data ${JSON.stringify(channel.openingData)}`
)
channel.send('hello!')
}
export const onDisconnect: ChannelDisconnection = async (services, channel) => {
services.logger.info(
`Disconnected from event channel with data ${JSON.stringify(channel.openingData)}`
)
}
export const authenticate: ChannelMessage<
{ token: string; userId: string },
{ authResult: boolean; action: 'auth' }
> = async (services, channel, data) => {
const authResult = data.token === 'valid'
if (authResult) {
await channel.setUserSession({ userId: data.userId })
}
return { authResult, action: 'auth' }
}
export const onMessage: ChannelMessage<'hello', 'hey'> = async (
services,
channel
) => {
services.logger.info(
`Got a generic hello message with data ${JSON.stringify(channel.openingData)}`
)
channel.send('hey')
}
And to register it:
import { addChannel } from '@vramework/core'
addChannel({
// The channel name, this is used to identify the channel with types in the client
// and needs to be unique
name: 'events',
// The route to use for the channel. For serverless this is usually / unless using
// a custom domain
route: '/',
// Called when a client connects to the channel
onConnect,
// Called when a client disconnects from the channel
onDisconnect,
// This is a global permission that applies to all message routes,
// unless overriden by the route
auth: true,
// The default message handler to use if no route is matched
onMessage,
onMessageRoute: {
// This supports AWS routing based on the json payload provided
action: {
// This function will set the user session, which
// means other functions will then work
auth: {
func: authenticate,
auth: false,
}
},
})
Why is that cool? Because most serverless frameworks struggle with real-time or require elaborate setups. Vramework’s function-based style lines up naturally with “events” in serverless contexts.
With serverless WebSockets, we provide:
- An eventHub service, so you can subscribe/unsubscribe/publish to clients based on topics or rooms. Each deployment has its own eventHub implementation, so using uws.js would be via C++, AWS would use its APIGateway API, and Cloudflare would use Durable Objects.
- A typed WebSocket client. We extract all the types from the functions and turn it into a typed client.
- A functional design approach, which means you can run WebSockets in the cloud without having to think about scalability or different architecture requirements.
Abstractions — The Trade-off
It’s rare for there to be a silver bullet, and Vramework, in its aim for simplicity, had to make a few decisions for it to work:
1) Abstractions
We don’t provide access to underlying servers and functions. This was done on purpose, as once the lines blur, you lose the ability to switch. That being said, some platforms (like Cloudflare <3) provide really interesting APIs on their serverless functions (like not blocking responses with waitUntil
). These may be exposed via polyfill functionality in the future.
2) Compile Time
Vramework figures out the routes to run and extracts all the metadata during compile time via TypeScript. This currently means, similar to tests, you need to run it on code change (changes only matter when types are changed or routes are added/removed). This was a deliberate decision because Vramework itself doesn’t require TypeScript during runtime, and the code that’s emitted is identical to what goes in. We only extract information and rely on types for everything to work as expected.
The Big Picture:
- Function-First: Less boilerplate, more focus on your logic.
- Serverless & Server: Perfect for those who want things to work both via server or serverless.
- Tiny Bundles: Vramework only adds around 150kb, and that’s mostly due to schema validation (which will be optional in the future).
- Unified Approach: HTTP routes, CRON tasks, and WebSocket events in one consistent function-first model.
- Strong Type Safety: Vramework auto-validates input data, attaches sessions/permissions, and ensures you can code confidently.
No more concerns about selecting a framework, messy code, or rewriting logic for every environment.
I Want Your Feedback!
That’s the gist of why I built Vramework. But this is still evolving, and I’d love to hear from you:
- Is vendor tie-in an actual issue?
- Is function-first “too” minimal, or just right?
- Ever had trouble mixing local + serverless or multi-cloud deployments?
- Is serverless WebSockets something you ever wanted to use but felt was too complex?
If you’re curious, check out:
- Official Guide for examples & setup
- GitHub Repo to star, open issues, or share ideas
Drop a comment below or open a discussion on GitHub. Your thoughts drive Vramework’s roadmap.
Top comments (0)