One Handler, Many Runtimes

A small backend should not need a large framework to stay useful.

It should have a clear handler shape, predictable routing, simple local execution, and a clean path to production.

That is the idea behind three Vyriy packages that can work together:

  • @vyriy/handler
  • @vyriy/router
  • @vyriy/server

Each package has a focused job.

@vyriy/handler keeps the Lambda-compatible handler shape.

@vyriy/router keeps request matching separate from runtime adapters.

@vyriy/server runs the same handler over HTTP for local development, Docker, or Fargate-style runtimes.

Together, they support a calm composition model: write the handler once, then run it where you need it.

The Goal

The goal is simple:

Write a Lambda-style handler and reuse the same shape across environments.

A handler should be able to run:

  • in AWS Lambda;
  • locally during development;
  • inside Docker;
  • behind a web server or reverse proxy;
  • in a Fargate-style HTTP runtime;
  • with normal responses;
  • with streaming responses.

The runtime may change.

The application shape should stay calm.

A Minimal Lambda-Compatible Handler

With @vyriy/handler, a basic API handler can stay small and explicit:

import { api } from '@vyriy/handler';

export const handler = api(async (event) => ({
  statusCode: 200,
  body: JSON.stringify({
    path: event.path,
  }),
}));

The handler keeps the familiar Lambda-style shape.

The event comes in.

A response object goes out.

There is no framework-specific controller class, decorator system, or hidden routing convention required.

This is useful because the handler remains portable. It can be used as a Lambda handler, but it can also be passed into a local server adapter.

Streaming Responses

Some APIs should not wait until the whole response is ready.

Logs, progress output, generated text, server-sent style flows, and long-running operations can benefit from streaming.

@vyriy/handler supports streaming through streamApi:

import { streamApi } from '@vyriy/handler';

export const handler = streamApi(async (event, responseStream) => {
  responseStream.setContentType?.('text/plain');
  responseStream.write(`Request path: ${event.path}\n`);
  responseStream.write('Part 1 of the response...');
  responseStream.end('Part 2 of the response...');
});

The shape is still explicit:

(event, responseStream, context);

The event is first.

The response stream is second.

The Lambda context is third.

That order matters because it keeps the function close to AWS Lambda response streaming while still being simple enough to run locally.

Run the Same Handler Locally

A Lambda-compatible handler is useful, but local development should not require deploying to AWS.

With @vyriy/server, the same handler can run over HTTP:

// server.ts
import { streamServer } from '@vyriy/server';

import { handler } from './handler.js';

streamServer(handler);

The handler can now run locally, in Docker, or in a Fargate-style HTTP runtime.

The important part is that the business handler did not change.

Only the entrypoint changed.

Run the Same Handler in AWS Lambda

For AWS Lambda response streaming, keep the AWS-specific wrapper in a Lambda entrypoint:

// lambda.ts
import { handler } from './handler.js';

export const main = awslambda.streamifyResponse(handler);

This keeps AWS-specific runtime code at the edge of the application.

The core handler stays reusable.

That separation is important. It prevents cloud runtime details from leaking into every file.

Inline Streaming Handler

For small APIs or experiments, a separate local entrypoint may not be necessary.

The same shape can also be written inline:

import { streamApi } from '@vyriy/handler';

export const main = awslambda.streamifyResponse(
  streamApi(async (event, responseStream) => {
    responseStream.setContentType?.('text/plain');
    responseStream.write(`Request path: ${event.path}\n`);
    responseStream.write('Part 1 of the response...');
    responseStream.end('Part 2 of the response...');
  }),
);

This is useful when the Lambda function is small and does not need separate composition yet.

But as soon as local development, Docker execution, or shared routing becomes useful, extracting the handler into its own file keeps the system calmer.

Calm Composition

Routing should not be mixed with runtime adaptation.

The router should match requests.

The handler wrapper should normalize the handler shape.

The server adapter should expose the handler over HTTP.

These responsibilities are related, but they are not the same.

A small API can stay as plain composition:

import { api } from '@vyriy/handler';
import { createRouter } from '@vyriy/router';
import { server } from '@vyriy/server';

const router = createRouter();

router.get('/health', () => ({
  body: JSON.stringify({
    ok: true,
  }),
}));

const handler = api((event) => router.route(event));

server(handler);

There is no hidden application container here.

The flow is visible:

HTTP request
  -> server adapter
  -> Lambda-style event
  -> handler wrapper
  -> router
  -> route response

That is the kind of boring architecture that is easy to debug.

Streaming Router Composition

The same composition model works for streaming responses.

import { streamApi } from '@vyriy/handler';
import { createStreamRouter } from '@vyriy/router';
import { streamServer } from '@vyriy/server';

const router = createStreamRouter();

router.get('/events', (_params, responseStream) => {
  responseStream.setContentType?.('text/plain');
  responseStream.end('ok');
});

const handler = streamApi((event, responseStream) => router.route(event, responseStream));

streamServer(handler);

The router handles matching.

The stream handler handles writing.

The server adapter handles HTTP.

Each layer has one clear reason to exist.

Lambda-Only Entrypoint

When the package is used only in Lambda, the same composition can be exported directly:

import { api } from '@vyriy/handler';
import { createRouter } from '@vyriy/router';

const router = createRouter();

router.get('/health', () => ({
  body: JSON.stringify({
    ok: true,
  }),
}));

export const handler = api((event) => router.route(event));

This keeps the Lambda function simple without losing the ability to reuse the same package structure later.

If local execution becomes necessary, add a server entrypoint.

The handler does not need to be redesigned.

Local HTTP Usage

@vyriy/server can run a Lambda-style handler directly over HTTP:

import { server } from '@vyriy/server';

server(async () => ({
  statusCode: 200,
  headers: {
    'content-type': 'application/json',
  },
  body: JSON.stringify({ ok: true }),
}));

This is the smallest useful local API.

It can be enough for health checks, mocks, experiments, local tools, and simple internal services.

Handler Wrapper Usage

Use api(...) when you want the handler package wrappers.

It keeps the same (event, context) Lambda handler shape:

import { api } from '@vyriy/handler';
import { server } from '@vyriy/server';

const handler = api(async (event) => ({
  statusCode: 200,
  body: JSON.stringify({
    ok: true,
    path: event.path,
  }),
}));

server(handler);

The server receives the handler.

The handler receives the event.

The response stays Lambda-compatible.

Local Streaming Usage

streamServer can run a streaming handler locally:

import { streamServer } from '@vyriy/server';

streamServer(async (event, responseStream) => {
  responseStream.setContentType?.('text/plain');
  responseStream.write(`Path: ${event.path}\n`);
  responseStream.end('Done');
});

This gives a local development path for response streaming without deploying every change.

Streaming With Handler Wrapper

Use streamApi(...) when you want the handler package wrappers and the Lambda response streaming shape:

import { streamApi } from '@vyriy/handler';
import { streamServer } from '@vyriy/server';

const handler = streamApi((event, responseStream) => {
  responseStream.setContentType?.('text/plain');
  responseStream.write(`Path: ${event.path}\n`);
  responseStream.end('Done');
});

streamServer(handler);

The same handler can then be wrapped by AWS Lambda:

import { handler } from './handler.js';

export const main = awslambda.streamifyResponse(handler);

AWS runtime code stays in the AWS entrypoint.

Local runtime code stays in the local entrypoint.

The application handler stays shared.

Static Files Stay Outside

Static files are intentionally left to the Docker or web-server layer.

For example:

  • Nginx;
  • Caddy;
  • a CDN;
  • a platform asset server;
  • a reverse proxy in front of the Node process.

This keeps the Node server focused on dynamic HTTP behavior.

It also matches a serverless-friendly deployment style: static assets can be served by the layer that is best at serving static assets.

The API does not need to become a static file server unless that is a real requirement.

Port Configuration

The server listens on PORT from @vyriy/env.

The default port is 3000.

That keeps local execution simple while still allowing Docker and platform runtimes to inject the port through environment configuration.

PORT=8080 node dist/server.js

The code does not need to hard-code deployment-specific port values.

Why This Shape Is Calm

This composition is calm because each package has a small responsibility.

@vyriy/handler answers:

How do I keep my function compatible with Lambda-style handlers?

@vyriy/router answers:

How do I route an event to focused request handlers?

@vyriy/server answers:

How do I run the same handler over HTTP locally or in a container?

The packages do not need to become one large framework.

They work together, but they remain replaceable.

That is the key design point.

Runtime Boundaries

A useful backend often has several boundaries:

application logic
  -> handler shape
  -> router
  -> runtime adapter
  -> platform

When these boundaries are explicit, the system is easier to move.

Local development can use server(...).

AWS Lambda can export handler.

Streaming Lambda can use awslambda.streamifyResponse(handler).

Docker can run the local server entrypoint.

A Fargate-style runtime can treat the app as a normal HTTP service.

The core behavior stays the same.

Suggested Project Shape

A small project can start like this:

src/
  handler.ts
  server.ts
  lambda.ts

Where:

handler.ts

contains the shared application handler.

server.ts

runs the handler locally or in Docker.

lambda.ts

exports the AWS Lambda entrypoint.

For a routed API:

src/
  routes/
    health.ts
    events.ts
  router.ts
  handler.ts
  server.ts
  lambda.ts

This is still simple.

There is no need to introduce a large application framework before the project actually needs one.

Practical Benefits

This approach gives several practical benefits.

First, local development becomes faster.

You can run the same handler without deploying to AWS.

Second, testing becomes simpler.

A handler can be called directly with an event object.

Third, deployment choices stay open.

The same code can run as Lambda, local HTTP, Docker, or a containerized service.

Fourth, streaming is not a separate architecture.

It is the same idea with a stream response object.

Finally, the public shape remains easy to explain.

That matters for long-term maintenance.

A Small API Example

A minimal non-streaming API can be composed like this:

import { api } from '@vyriy/handler';
import { createRouter } from '@vyriy/router';

const router = createRouter();

router.get('/health', () => ({
  headers: {
    'content-type': 'application/json',
  },
  body: JSON.stringify({
    ok: true,
  }),
}));

export const handler = api((event) => router.route(event));

The same handler can be used by a local server entrypoint or a Lambda entrypoint.

That is the main idea.

Conclusion

The point is not to avoid frameworks forever.

The point is to start with a shape that is easy to understand before adding more.

A handler is a small boundary.

A router is a small boundary.

A server adapter is a small boundary.

When these boundaries are composed explicitly, the application remains portable and calm.

@vyriy/handler, @vyriy/router, and @vyriy/server are designed around that idea.

Write the handler once.

Run it locally.

Run it in Docker.

Run it in AWS Lambda.

Keep the runtime details at the edges.

Keep the core application simple.