Unified Hosting Model

Benzene lets you write one message handler and run it, unchanged, on Express, AWS Lambda, Azure Functions, Google Cloud Functions, or a self-hosted worker process. Only the small piece of code that wires a transport to your handler changes between hosts — the handler itself never moves.

TypeScript port. This is the TypeScript port of Benzene. The .NET original centres this model on a single BenzeneStartUp class run through per-platform production host adapters. Those *Host<TStartUp> production hosts ARE now ported for the three serverless cloudsAwsLambdaHost<TStartUp> (@benzene/aws-lambda-core), AzureFunctionHost<TStartUp> (@benzene/azure-function-core), and GoogleCloudFunctionHost<TStartUp> / GooglePubSubFunctionHost<TStartUp> (@benzene/google-cloud-functions-*). You write one StartUp implementing the canonical BenzeneStartUp contract (from @benzene/abstractions-middleware) and boot it with a one-linerexport const handler = new AwsLambdaHost(StartUp).lambdaHandler, new AzureFunctionHost(StartUp).httpFunction, new GoogleCloudFunctionHost(StartUp).httpFunction — the SAME composition root a component test boots (benzeneTestHost(StartUp)), so what you test is what deploys. This is the recommended entry for all three clouds.

The terse fluent inline builders (InlineAwsLambdaStartUp, InlineAzureFunctionStartUp, InlineSelfHostedStartUp) remain for inline tests and small standalone hosts — the advanced/terse alternative, built over the same platform-neutral IBenzeneApplicationBuilder model described below. Express is a middleware factory (benzene(...)); the self-hosted worker keeps its inline builder (no *Host production adapter — it owns its own process lifecycle). Where a shape differs, the README's Porting conventions explain why.

The through-line: one handler, many hosts

Benzene separates what your service does from how it's invoked:

Because only the transport pipeline changes between hosts, the handler runs unchanged everywhere. This page shows the same handler served five ways.

Three ways Benzene starts

Every host below falls into one of three execution models. Which one you're in determines who owns the process and whether anything is listening or polling.

1. Triggered (serverless) — AWS Lambda, Azure Functions, Google Cloud Functions. Nothing runs until the platform invokes your code for a single event. There is no Benzene-owned process and nothing polls; the platform's own infrastructure (API Gateway, an SQS/Service Bus/Event Hub trigger, an HTTP/Pub/Sub Cloud Function, …) calls into a cold or warm instance per invocation. See AWS Lambda Setup, Azure Functions Setup, and Google Cloud Functions.

2. Embedded in an existing host — Express. A pre-existing, already-long-running listener owns the process and its own concurrency model: one incoming request is one async call. Benzene is just middleware inside that pipeline — the value benzene(...) returns — and never starts, stops, or paces anything about the host process. This is the TypeScript port's counterpart to the .NET Benzene.AspNet.Core host on Kestrel.

3. Self-hosted worker@benzene/self-host. Here Benzene itself owns a long-running consumer that actively receives work (a broker poll loop) and keeps the process alive — no external infrastructure invokes you, and no separate host is already listening. This is the one mode where how many events run at once is Benzene's own decision; see Worker concurrency.

Not yet ported. Two host shapes from the .NET model have no TypeScript equivalent: ASP.NET Core / Kestrel (use Express in its place), and the generic IHostedService worker host (Benzene.HostedService) — the .NET generic-host adapter that owns a worker process's start/stop lifecycle; instead, drive the composite worker's startAsync/stopAsync from your own process lifecycle (below). gRPC hosting is ported — @benzene/grpc's useGrpc bridges a @grpc/grpc-js Server into the same handler pipeline (the grpc-js Server replaces .NET's ASP.NET-hosted gRPC). And on the self-hosted side, both the platform-neutral worker scaffolding (@benzene/self-host) and the ready-made broker/stream consumers — SQS, Service Bus, Event Hub, RabbitMQ, Kafka, and the Cosmos DB change feed — are ported, each added with a use* call on the worker startup. See Self-hosted worker below.

The shared handler

Write this once. It's the only file you carry over verbatim between every host on this page:

// src/handlers.ts
import { IBenzeneResultOf } from '@benzene/abstractions';
import { IMessageHandler } from '@benzene/abstractions-message-handlers';
import { message } from '@benzene/core-message-handlers';
import { httpEndpoint } from '@benzene/http';
import { BenzeneResult } from '@benzene/results';

// Payloads are classes, not interfaces: the runtime recovers the erased request type from its
// constructor (for topic/schema keying), which an interface can't provide.
export class PlaceOrder {
  customerId?: string;
}

export class OrderConfirmation {
  orderId?: string;
}

@httpEndpoint('POST', '/orders')
@message('order:place', { requestType: PlaceOrder, responseType: OrderConfirmation })
export class PlaceOrderHandler implements IMessageHandler<PlaceOrder, OrderConfirmation> {
  handleAsync(request: PlaceOrder): Promise<IBenzeneResultOf<OrderConfirmation>> {
    const confirmation = new OrderConfirmation();
    confirmation.orderId = `order-${request.customerId ?? 'anon'}`;
    return Promise.resolve(BenzeneResult.created(confirmation));
  }
}

@message('order:place', …) maps the handler to its topic and self-registers it when the module loads; @httpEndpoint('POST', '/orders') maps an HTTP method and path onto that same topic. See Message Handlers for the full picture.

The same handler on five hosts

Every snippet below serves the exact PlaceOrderHandler above. Notice the shape is always the same: build a transport pipeline, and inside it call useMessageHandlers(pipeline, PlaceOrderHandler) — the step that routes a matched request to its handler by topic. Pass every handler class you want served.

Express — benzene(...)

Package: @benzene/express. benzene(...) returns Express middleware that inserts Benzene into the request pipeline; the transport pipeline is configured in the callback it takes.

// src/index.ts
import express from 'express';
import { useMessageHandlers } from '@benzene/core-message-handlers';
import { benzene } from '@benzene/express';
import { PlaceOrderHandler } from './handlers.js';

const app = express();

// Mount Benzene BEFORE any body parser so it reads the raw request body.
app.use(benzene((pipeline) => useMessageHandlers(pipeline, PlaceOrderHandler)));

app.listen(3000, () => console.log('Listening on http://localhost:3000'));

The Benzene middleware only responds to requests that match one of your @httpEndpoint routes; anything else falls through to the rest of the Express app, so it coexists cleanly with existing routes. See Getting Started for the full walkthrough.

AWS Lambda — AwsLambdaHost

Package: @benzene/aws-lambda-core (plus one transport package per event source) — or the @benzene/aws-lambda umbrella, which bundles the core and every event-source transport under one install and re-exports the granular names shown here (as AWS Lambda Setup uses). Like Azure and Google, the AWS host is the host-class shape: you write one StartUp class implementing the same BenzeneStartUp contract as every other host, pass it to AwsLambdaHost, and export its .lambdaHandler. Inside configure, select AWS with useAwsLambda(app, aws => …):

// src/startUp.ts
import { IBenzeneServiceContainer } from '@benzene/abstractions';
import { BenzeneConfiguration, BenzeneStartUp, IBenzeneApplicationBuilder } from '@benzene/abstractions-middleware';
import { addBenzene, useMessageHandlers } from '@benzene/core-message-handlers';
import { useAwsLambda } from '@benzene/aws-lambda-core';
import { useApiGateway } from '@benzene/aws-lambda-api-gateway';
import { PlaceOrderHandler } from './handlers.js';

export class StartUp implements BenzeneStartUp {
  configureServices(services: IBenzeneServiceContainer, _config: BenzeneConfiguration): void {
    addBenzene(services);
  }
  configure(app: IBenzeneApplicationBuilder, _config: BenzeneConfiguration): void {
    useAwsLambda(app, (aws) => useApiGateway(aws, (api) => useMessageHandlers(api, PlaceOrderHandler)));
  }
}
// src/handler.ts — the one-liner boot AWS invokes.
import { AwsLambdaHost } from '@benzene/aws-lambda-core';
import { StartUp } from './startUp.js';

export const handler = new AwsLambdaHost(StartUp).lambdaHandler;

The one-liner new AwsLambdaHost(StartUp).lambdaHandler boots the same StartUp a component test boots (benzeneTestHost(StartUp).buildAwsLambdaHost()), so what you test is what deploys.

Export the bound handler. Always write export const handler = new AwsLambdaHost(StartUp).lambdaHandler, never export const handler = host.functionHandlerAsync — assigning the method detaches this and the pipeline is lost at the first invocation. See AWS Lambda Setup.

InlineAwsLambdaStartUp still works. The terse fluent inline builder remains for inline tests and small standalone hosts (new InlineAwsLambdaStartUp().configure(app => useApiGateway(app, …)).build() returns the entry point, wrapped with toLambdaHandler(...)). The AwsLambdaHost one-liner is the taught path.

Azure Functions — AzureFunctionHost

Package: @benzene/azure-function-core (plus one transport package per trigger type). Like Google, the Azure host is the host-class shape: you write one StartUp class implementing the same BenzeneStartUp contract as every other host, pass it to AzureFunctionHost, and it hands you the native-trigger handler to register with the @azure/functions v4 API. Inside configure, select Azure with useAzureFunctions(app, az => …) — the exact counterpart of AWS's useAwsLambda(app, aws => …):

// src/startUp.ts
import { IBenzeneServiceContainer } from '@benzene/abstractions';
import { BenzeneConfiguration, BenzeneStartUp, IBenzeneApplicationBuilder } from '@benzene/abstractions-middleware';
import { addBenzene, useMessageHandlers } from '@benzene/core-message-handlers';
import { useAzureFunctions } from '@benzene/azure-function-core';
import { useAzureHttp } from '@benzene/azure-function-http';
import { PlaceOrderHandler } from './handlers.js';

export class HttpStartUp implements BenzeneStartUp {
  configureServices(services: IBenzeneServiceContainer, _config: BenzeneConfiguration): void {
    addBenzene(services);
  }
  configure(app: IBenzeneApplicationBuilder, _config: BenzeneConfiguration): void {
    useAzureFunctions(app, (az) => useAzureHttp(az, (http) => useMessageHandlers(http, PlaceOrderHandler)));
  }
}
// src/functions.ts — importing the HTTP package lights up the host's `.httpFunction` getter.
import { AzureFunctionHost } from '@benzene/azure-function-core';
import '@benzene/azure-function-http';
import { HttpStartUp } from './startUp.js';

/** The `@azure/functions` HTTP handler to register with `app.http(...)`. */
export const placeOrderHttp = new AzureFunctionHost(HttpStartUp).httpFunction;

The one-liner new AzureFunctionHost(StartUp).httpFunction boots the same StartUp a component test boots (benzeneTestHost(StartUp).buildAzureFunctionApp()), so what you test is what deploys. Each trigger package adds its own getter — .serviceBusFunction (@benzene/azure-function-service-bus), .eventHubFunction (@benzene/azure-function-event-hub) — over its handle* dispatch, so a fire-and-forget trigger reads the same way. You then register placeOrderHttp with app.http(...) at module load. See Azure Functions Setup for registration, host.json, and non-HTTP triggers.

InlineAzureFunctionStartUp still works. The fluent inline builder remains for inline tests and small standalone hosts (new InlineAzureFunctionStartUp().configure(app => useAzureHttp(app, …)).build() returns the IAzureFunctionApp directly). The AzureFunctionHost one-liner is the taught path.

Google Cloud Functions — GoogleCloudFunctionHost

Package: @benzene/google-cloud-functions-http (HTTP) and @benzene/google-cloud-functions-pubsub (Pub/Sub). The same host-class shape as AWS and Azure: you write one StartUp implementing the same BenzeneStartUp contract, pass it to GoogleCloudFunctionHost, and export its .httpFunction. Inside configure, select Google with useGoogleCloud(app, g => …) — the exact counterpart of AWS's useAwsLambda(app, aws => …) and Azure's useAzureFunctions(app, az => …):

// src/startUp.ts
import { IBenzeneServiceContainer } from '@benzene/abstractions';
import { BenzeneConfiguration, BenzeneStartUp, IBenzeneApplicationBuilder } from '@benzene/abstractions-middleware';
import { addBenzene, useMessageHandlers } from '@benzene/core-message-handlers';
import { useGoogleCloud } from '@benzene/google-cloud-functions-core';
import { useHttp } from '@benzene/google-cloud-functions-http';
import { PlaceOrderHandler } from './handlers.js';

export class OrdersStartUp implements BenzeneStartUp {
  configureServices(services: IBenzeneServiceContainer, _config: BenzeneConfiguration): void {
    addBenzene(services);
  }
  configure(app: IBenzeneApplicationBuilder, _config: BenzeneConfiguration): void {
    useGoogleCloud(app, (g) => useHttp(g, (http) => useMessageHandlers(http, PlaceOrderHandler)));
  }
}
// src/function.ts — `.httpFunction` is the `HttpFunction` the Functions Framework invokes.
import { GoogleCloudFunctionHost } from '@benzene/google-cloud-functions-http';
import { OrdersStartUp } from './startUp.js';

export const ordersFunction = new GoogleCloudFunctionHost(OrdersStartUp).httpFunction;

For a Pub/Sub-triggered function, swap useHttp for usePubSub and GoogleCloudFunctionHost for GooglePubSubFunctionHost (.cloudEventFunction) — everything else is identical. See Google Cloud Functions for the full walkthrough and deployment.

Self-hosted worker — InlineSelfHostedStartUp

Package: @benzene/self-host. Unlike the hosts above, a worker owns a long-running process rather than responding to an external caller. InlineSelfHostedStartUp registers services and one or more IBenzeneWorkers, then build() returns a single composite worker with startAsync/stopAsync you drive from your process's lifecycle:

// src/worker.ts
import { addBenzene } from '@benzene/core-message-handlers';
import { InlineSelfHostedStartUp } from '@benzene/self-host';
import { OrdersConsumer } from './OrdersConsumer.js';

const worker = new InlineSelfHostedStartUp()
  .configure((workers) => workers.add((resolver) => new OrdersConsumer(resolver)))
  .configureServices((services) => addBenzene(services))
  .build();

await worker.startAsync();

// Keep the process alive; drain in-flight work on shutdown.
process.on('SIGTERM', () => void worker.stopAsync());

workers.add((resolver) => …) registers a factory that builds one worker from the invocation's resolver factory; register several and build() composes them into one CompositeBenzeneWorker that starts and stops them together (see @benzene/self-host's CompositeBenzeneWorker).

OrdersConsumer is your own IBenzeneWorker — a small interface with startAsync(signal?) / stopAsync(signal?) (from @benzene/abstractions-middleware). Its poll loop receives broker messages and dispatches each one into a Benzene message pipeline, so the same PlaceOrderHandler runs here too.

Ready-made self-hosted consumers

You rarely have to write that poll loop yourself. Each of the common brokers and change streams ships a ready-made consumer worker in its own package, added with a use* free function that takes the configure callback's worker startup (IBenzeneWorkerStartup) as its first argument — the same free-function-taking-the-builder-first shape as the transport use* functions on the other hosts. The call registers the consumer's services, builds its inner pipeline from the action you pass, and adds the worker to the composite:

Package use* function Transport Inner pipeline
@benzene/aws-sqs useSqs(workers, config, clientFactory, action) "sqs" useMessageHandlers(...)
@benzene/azure-service-bus useServiceBus(workers, config, clientFactory, action) "service-bus" useMessageHandlers(...)
@benzene/azure-event-hub useEventHub(workers, config, processorClientFactory, action) "event-hub" useMessageHandlers(...)
@benzene/rabbitmq useRabbitMq(workers, config, connectionFactory, action) "rabbitmq" useMessageHandlers(...)
@benzene/kafka-core useKafka(workers, config, consumerFactory, action) "kafka" useMessageHandlers(...)
@benzene/azure-cosmos-db useCosmosDbChangeFeed(workers, config, processorFactory, action) "cosmos-db" useStream(...)

The message-based consumers route by topic, so their action is the same useMessageHandlers(pipeline, PlaceOrderHandler) you write on every other host — the same PlaceOrderHandler runs unchanged. Only the first three arguments (the broker config and its client factory) are broker-specific; each factory is the seam where you hand in your own SDK client so the package prescribes nothing about your credentials or connection. Here's the SQS consumer, wired onto the worker startup:

// src/worker.ts
import { SQSClient } from '@aws-sdk/client-sqs';
import { useMessageHandlers } from '@benzene/core-message-handlers';
import { SqsClientFactory, useSqs } from '@benzene/aws-sqs';
import { InlineSelfHostedStartUp } from '@benzene/self-host';
import { PlaceOrderHandler } from './handlers.js';

const worker = new InlineSelfHostedStartUp()
  .configure((workers) =>
    useSqs(
      workers,
      { queueUrl: process.env.QUEUE_URL!, maxNumberOfMessages: 10 },
      new SqsClientFactory(new SQSClient({})),
      (pipeline) => useMessageHandlers(pipeline, PlaceOrderHandler),
    ),
  )
  .build();

await worker.startAsync();
process.on('SIGTERM', () => void worker.stopAsync());

The use* call registers Benzene's base services itself, so you don't need a configureServices addBenzene step for a worker that only hosts ready-made consumers. Register more than one — call useSqs, useRabbitMq, … in the same configure body, each chaining on the same workers — and build() composes them into one CompositeBenzeneWorker that starts and stops them together.

Cosmos DB change feed — useCosmosDbChangeFeed

The Cosmos DB change-feed consumer is the one stream (not message) transport here: changed documents carry no message envelope, so its pipeline is a streaming pipeline over the document type (useStream(...) from @benzene/core-middleware) rather than useMessageHandlers. This is the standalone worker — distinct from the Azure Functions CosmosDBTrigger adapter (@benzene/azure-function-cosmos-db, see Azure Functions Setup); reach for this one when you want a long-running @benzene/self-host worker with manual per-batch checkpoint control.

Its third argument is an ICosmosChangeFeedProcessorFactory<TDocument> — the built-in CosmosChangeFeedProcessorFactory takes the monitored container and an ICosmosChangeFeedCheckpointStore (where the continuation-token checkpoint is persisted). The port ships InMemoryCosmosChangeFeedCheckpointStore for dev and tests; it is not durable (tokens live only for the process's lifetime, so a restart resumes from the config's startFrom rather than the last processed change), so a production worker supplies its own store backed by a Cosmos container, blob, table, etc.

// src/cosmos-worker.ts
import { CosmosClient } from '@azure/cosmos';
import { useStream } from '@benzene/core-middleware';
import {
  BenzeneCosmosChangeFeedConfig,
  CosmosChangeFeedProcessorFactory,
  InMemoryCosmosChangeFeedCheckpointStore,
  useCosmosDbChangeFeed,
} from '@benzene/azure-cosmos-db';
import { InlineSelfHostedStartUp } from '@benzene/self-host';

class OrderDocument {
  orderId?: string;
}

const container = new CosmosClient(process.env.COSMOS_CONNECTION_STRING!)
  .database('shop')
  .container('orders');

const worker = new InlineSelfHostedStartUp()
  .configure((workers) =>
    useCosmosDbChangeFeed<OrderDocument>(
      workers,
      new BenzeneCosmosChangeFeedConfig(),
      new CosmosChangeFeedProcessorFactory<OrderDocument>(
        container,
        // Dev/test only — production needs a durable checkpoint store.
        new InMemoryCosmosChangeFeedCheckpointStore(),
      ),
      (feed) =>
        useStream<OrderDocument>(feed, async (documents: AsyncIterable<OrderDocument>) => {
          for await (const document of documents) {
            console.log('changed order', document.orderId);
          }
        }),
    ),
  )
  .build();

await worker.startAsync();
process.on('SIGTERM', () => void worker.stopAsync());

For deletes and intermediate versions, useCosmosDbAllVersionsChangeFeed(...) is the all-versions-and-deletes sibling: its stream is over CosmosChangeFeedItem<TDocument> (current + previous + change type) instead of the bare document, and — being automatic-checkpoint only — its config is BenzeneCosmosAllVersionsChangeFeedConfig. It requires the caller to have configured container/account retention, otherwise deletes and intermediate versions don't surface.

If none of the ready-made consumers fits your broker, you can still write your own IBenzeneWorker and register it with workers.add((resolver) => …), exactly as the OrdersConsumer example above does.

Two AWS deployment shapes

On AWS you have a deployment choice that doesn't change the transport wiring inside configure:

Both are covered in full, with runnable code, in AWS Lambda Setup.

IBenzeneApplicationBuilder

The app passed to each host's configure step is an IBenzeneApplicationBuilder (@benzene/abstractions-middleware) — the platform-neutral builder every host implements:

export interface IBenzeneApplicationBuilder extends IRegisterDependency {
  readonly platform: string;              // "AwsLambda", "AzureFunctions", "Worker", ...
  create<TContext>(): IMiddlewarePipelineBuilder<TContext>;
}

Each platform's use* transport function is a free function taking the builder first and pattern-matching on the concrete builder type, so calling the wrong one for the running host is a safe no-op. useWorker (from @benzene/self-host) is the clearest example:

export function useWorker(
  app: IBenzeneApplicationBuilder,
  configure: (workers: IBenzeneWorkerStartup) => void,
): IBenzeneApplicationBuilder {
  if (app instanceof WorkerApplicationBuilder) {
    configure(app.workers);
  }
  return app;
}

This is the free-function port of the .NET Use* extension methods (see the Porting conventions), and it's why a configure body can call useWorker(...) even under a host that isn't the worker host — it simply does nothing there.

IBenzeneInvocation

IBenzeneInvocation (@benzene/abstractions-middleware) is a platform-neutral bag of metadata about the current invocation, so a handler can stay portable while still reaching native platform context when it genuinely needs to:

export interface IBenzeneInvocation {
  readonly invocationId: string;
  readonly platform: string;
  getFeature<T>(feature: ServiceIdentifier<T>): T | undefined;
}

Enable it by calling useBenzeneInvocation() on the pipeline builder inside configure; resolve IBenzeneInvocation as a scoped dependency. It's populated once per pipeline by whichever level's useBenzeneInvocation() you called, so call it at the level you need it resolvable from.

Port scope. useBenzeneInvocation() is wired for AWS Lambda (@benzene/aws-lambda-core, where invocationId is the Lambda request ID and getFeature exposes the Lambda Context) over the platform-neutral core (@benzene/core-middleware). The Express and Azure Functions accessors from the .NET model aren't ported yet.

Worker concurrency

For the self-hosted worker (mode 3), how many events run at once is Benzene's decision. The port ships BoundedConcurrentDispatcher<T> (@benzene/self-host) — a fan-out primitive a worker's poll loop hands each received item to:

new BoundedConcurrentDispatcher<Message>(laneCount, handle, logger, {
  keySelector: (m) => m.partition,   // same key → same lane → per-key order preserved
  catchExceptions: true,             // default: a lane logs and swallows a fault, keeps consuming
});

Node has no System.Threading.Channels, so the used subset is re-created in-package as a capacity-1 single-reader BoundedChannel. The ready-made consumers wire this dispatcher for you and surface its knobs on their configs — e.g. concurrentRequests and preserveOrderPerPartition on the Kafka config, prefetchCount/concurrentRequests/drainTimeoutMs on RabbitMQ, maxConcurrentCalls/prefetchCount on Service Bus — so you rarely touch BoundedConcurrentDispatcher directly. Reach for it only when you write your own IBenzeneWorker, where you hand each received item to it yourself.

Testing

You don't need a real cloud host to test any of these. Build the same entry point your host ships, construct a native event with the transport's test helper, and invoke it — the request runs your real pipeline end-to-end. @benzene/testing supplies the payload builders (httpBuilder, messageBuilder), and @benzene/aws-lambda-testing / @benzene/azure-function-testing turn them into native events. See Testing Benzene.

See Also