Getting Started: Benzene on Google Cloud Functions

Benzene runs on Google Cloud Functions (Gen2), serving both HTTP-triggered and Pub/Sub-triggered functions from a single set of message handlers. This guide starts from an empty folder and ends with an HTTP function answering POST /orders, then adds a Pub/Sub function so you can see the same handlers reached over a second trigger without rewriting a line of your logic.

If you're brand new to Benzene, read Getting Started first — it builds the same kind of service locally on Express in about five minutes. The message handler you write there runs unchanged on Cloud Functions; only the entry point differs, and that's what this guide covers.

TypeScript port. This is the TypeScript port of Benzene. It mirrors the .NET library's shape as closely as the language allows; where the two differ, the README's Porting conventions explain why. Two adaptations matter here. First, the .NET production host is GoogleCloudFunctionHost<TStartUp> where TStartUp : BenzeneStartUp (with its IConfiguration thread); the generic-host BenzeneStartUp has no port yet, so the port's startup is a minimal two-method contract — GoogleCloudFunctionStartUp (HTTP) and GooglePubSubFunctionStartUp (Pub/Sub) — with no configuration parameter. Second, the .NET host is the Functions Framework entry point; Node's Functions Framework invokes a registered named handler, so the TS host instead exposes a bound closure (host.httpFunction / host.cloudEventFunction) you register with the framework.

What you'll build

An HTTP-triggered function that handles POST /orders, and a Pub/Sub-triggered function that consumes messages off a subscription — both driven by the same transport-agnostic handlers.

Prerequisites

The core idea in 30 seconds

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

On Cloud Functions the pipeline is built by a startup (configureServices + configure) that a host boots. You register the host's handler with the Functions Framework and point gcloud functions deploy --entry-point at it. The handler itself is identical to the one you'd host on Express or AWS Lambda.

1. Create the project

mkdir orders-gcf && cd orders-gcf
npm init -y
npm pkg set type=module

Setting type=module makes this an ES-module project, which Benzene's packages require.

2. Install the packages

npm install @benzene/google-cloud-functions-http @benzene/google-cloud-functions-pubsub \
  @benzene/core-message-handlers @benzene/http @benzene/results \
  @benzene/abstractions @benzene/abstractions-message-handlers \
  @google-cloud/functions-framework
npm install --save-dev typescript

@benzene/google-cloud-functions-http brings the HTTP host (GoogleCloudFunctionHost) and its useHttp wiring; @benzene/google-cloud-functions-pubsub brings the Pub/Sub host (GooglePubSubFunctionHost) and usePubSub. @benzene/core-message-handlers supplies addBenzene, the @message decorator, and useMessageHandlers; @benzene/http adds @httpEndpoint; @benzene/results supplies BenzeneResult; the @benzene/abstractions* packages supply the IMessageHandler / IBenzeneResultOf / IBenzeneServiceContainer types. @google-cloud/functions-framework is the Google runtime you register the host with (and the source of its Request/Response/CloudEvent types).

3. Write a message handler

Create src/handlers.ts. This is where your logic lives — the file you'd carry over verbatim if you later moved to Express or Lambda:

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 {
  name?: string;
}

export class OrderDto {
  id?: string;
  name?: string;
}

@httpEndpoint('POST', '/orders')
@message('order:place', { requestType: PlaceOrder, responseType: OrderDto })
export class PlaceOrderHandler implements IMessageHandler<PlaceOrder, OrderDto> {
  handleAsync(request: PlaceOrder): Promise<IBenzeneResultOf<OrderDto>> {
    return Promise.resolve(BenzeneResult.created<OrderDto>({ id: 'order-1', name: request.name }));
  }
}

Two decorators do the wiring:

BenzeneResult.created(...) is the success case that maps to HTTP 201; use BenzeneResult.ok(...) for 200 and BenzeneResult.accepted(...) for 202. The result carries success/failure status alongside the payload — see Message Result.

Request binding. Benzene binds the JSON request body onto your request object, so a POST with {"name":"acme"} populates request.name. Unlike .NET, the TypeScript port does not bind path/query segments onto a bodyless request, so this guide uses a POST body. Read values a client sends in the body.

4. Define the startup and the HTTP entry point

Create src/http.ts. The startup is the platform-neutral pair of methods every Benzene host boots from; only the entry-point registration at the bottom is Google-specific:

import { IBenzeneServiceContainer } from '@benzene/abstractions';
import { addBenzene, useMessageHandlers } from '@benzene/core-message-handlers';
import {
  GoogleCloudFunctionApplicationBuilder,
  GoogleCloudFunctionHost,
  GoogleCloudFunctionStartUp,
  useHttp,
} from '@benzene/google-cloud-functions-http';
import * as functions from '@google-cloud/functions-framework';
import { PlaceOrderHandler } from './handlers.js';

class OrdersStartUp implements GoogleCloudFunctionStartUp {
  configureServices(services: IBenzeneServiceContainer): void {
    // Register your own services here. addBenzene wires the core pipeline.
    addBenzene(services);
  }

  configure(app: GoogleCloudFunctionApplicationBuilder): void {
    useHttp(app, (http) => useMessageHandlers(http, PlaceOrderHandler));
  }
}

// Build the host once, then register its bound handler with the Functions Framework.
const host = new GoogleCloudFunctionHost(OrdersStartUp);
functions.http('orders', host.httpFunction);

What each step does:

Register the bound closure — this is the one gotcha. Always register host.httpFunction. It is a closure bound to the host, so this stays attached. Do not register host.handleAsync directly — passing the method detaches this and the function loses its pipeline. host.httpFunction avoids that trap, the same way toLambdaHandler does for AWS.

5. Deploy

Compile src/ to JavaScript (e.g. npx tsc) and set your package.json "main" to the built file that calls functions.http(...), so the module registers the function when the Functions Framework loads it. Then deploy with the entry-point name you registered:

gcloud functions deploy orders \
  --gen2 --runtime nodejs22 --region europe-west2 \
  --source . --entry-point orders \
  --trigger-http --allow-unauthenticated

--entry-point orders matches the name you passed to functions.http('orders', …); the Functions Framework does the rest. When it finishes it prints the function URL:

curl -X POST "$(gcloud functions describe orders --gen2 --region europe-west2 --format 'value(serviceConfig.uri)')/orders" \
  -H "Content-Type: application/json" -d '{"name":"acme"}'
{"id":"order-1","name":"acme"}

6. Add a second trigger: Pub/Sub

The whole point of Benzene is that a handler doesn't care which trigger delivered its message. Add a handler that reacts to an order being created — the same shape, a different topic, and no @httpEndpoint, so it's reached only over Pub/Sub:

// add to src/handlers.ts
export class OrderCreated {
  id?: string;
  name?: string;
}

export class WarehouseAck {
  accepted?: boolean;
}

@message('order:created', { requestType: OrderCreated, responseType: WarehouseAck })
export class NotifyWarehouseHandler implements IMessageHandler<OrderCreated, WarehouseAck> {
  handleAsync(request: OrderCreated): Promise<IBenzeneResultOf<WarehouseAck>> {
    // ... notify the warehouse
    return Promise.resolve(BenzeneResult.ok<WarehouseAck>({ accepted: true }));
  }
}

A Pub/Sub trigger is a different Cloud Functions trigger type from HTTP, so it gets its own startup and host — but it still uses the same handler classes. Create src/pubsub.ts:

import { IBenzeneServiceContainer } from '@benzene/abstractions';
import { addBenzene, useMessageHandlers } from '@benzene/core-message-handlers';
import {
  GooglePubSubFunctionApplicationBuilder,
  GooglePubSubFunctionHost,
  GooglePubSubFunctionStartUp,
  usePubSub,
} from '@benzene/google-cloud-functions-pubsub';
import * as functions from '@google-cloud/functions-framework';
import { NotifyWarehouseHandler } from './handlers.js';

class OrdersPubSubStartUp implements GooglePubSubFunctionStartUp {
  configureServices(services: IBenzeneServiceContainer): void {
    addBenzene(services);
  }

  configure(app: GooglePubSubFunctionApplicationBuilder): void {
    usePubSub(app, (pubsub) => useMessageHandlers(pubsub, NotifyWarehouseHandler));
  }
}

const host = new GooglePubSubFunctionHost(OrdersPubSubStartUp);
functions.cloudEvent('orders-pubsub', host.cloudEventFunction);

usePubSub mirrors useHttp, and functions.cloudEvent('orders-pubsub', host.cloudEventFunction) registers the bound CloudEvent handler (the Pub/Sub analog of host.httpFunction). Pub/Sub delivers exactly one message per invocation, and the Benzene topic is read from the message's "topic" attribute by default — so @message('order:created', …) handles a message published with topic: order:created. (Pass a custom attribute key as the trailing argument to usePubSub(app, action, configure?, topicAttributeKey) if your producer uses a different key.) Delivery is fire-and-consume: there is no response to write back.

Deploy it as a second function pointed at a topic:

gcloud functions deploy orders-pubsub \
  --gen2 --runtime nodejs22 --region europe-west2 \
  --source . --entry-point orders-pubsub \
  --trigger-topic orders

7. Test it

Before deploying, exercise both startups in-memory with the same wiring you'll ship, using the test-helper packages. Boot the real startup with benzeneTestHost(...) from @benzene/testing, override any dependency with .withServices(...), then finish with the one GCP-specific line — buildGoogleCloudFunctionHost(...) for HTTP or buildGooglePubSubFunctionHost(...) for Pub/Sub:

npm install --save-dev vitest @benzene/testing \
  @benzene/google-cloud-functions-http-testing @benzene/google-cloud-functions-pubsub-testing

HTTP — build a native request with asGoogleCloudHttpRequest(httpBuilder(...)), push it in with host.sendHttpAsync(...), and assert on the returned response:

// test/http.test.ts
import { describe, expect, it } from 'vitest';
import { benzeneTestHost, httpBuilder } from '@benzene/testing';
import { asGoogleCloudHttpRequest, buildGoogleCloudFunctionHost } from '@benzene/google-cloud-functions-http-testing';
import { OrdersStartUp } from '../src/http.js'; // export OrdersStartUp to test it

describe('orders (HTTP)', () => {
  it('POST /orders returns a 201 with the mapped body', async () => {
    const host = buildGoogleCloudFunctionHost(benzeneTestHost(OrdersStartUp));

    const request = asGoogleCloudHttpRequest(httpBuilder('POST', '/orders', { name: 'acme' }));
    const response = await host.sendHttpAsync(request);

    expect(response.statusCode).toBe(201); // BenzeneResult.created -> 201
    expect(JSON.parse(response.body)).toMatchObject({ name: 'acme' });
  });
});

Pub/Sub — build a message with asPubSubEvent(messageBuilder(topic, body)) (or PubSubMessageBuilder) and push it in with host.sendPubSubAsync(...). Pub/Sub has no response, so assert on egress — what the handler published — through a faked IBenzeneMessageSender:

// test/pubsub.test.ts
import { describe, expect, it } from 'vitest';
import { IBenzeneServiceContainer } from '@benzene/abstractions';
import { IBenzeneMessageSender } from '@benzene/clients';
import { addBenzene, useMessageHandlers } from '@benzene/core-message-handlers';
import { benzeneTestHost, FakeBenzeneMessageSender, messageBuilder } from '@benzene/testing';
import {
  GooglePubSubFunctionApplicationBuilder,
  GooglePubSubFunctionStartUp,
  usePubSub,
} from '@benzene/google-cloud-functions-pubsub';
import { asPubSubEvent, buildGooglePubSubFunctionHost } from '@benzene/google-cloud-functions-pubsub-testing';

// A startup whose handler publishes 'order:created' downstream via IBenzeneMessageSender.
class PublishingStartUp implements GooglePubSubFunctionStartUp {
  configureServices(services: IBenzeneServiceContainer): void {
    addBenzene(services);
  }
  configure(app: GooglePubSubFunctionApplicationBuilder): void {
    usePubSub(app, (pubsub) => useMessageHandlers(pubsub, /* your handler */));
  }
}

describe('orders (Pub/Sub)', () => {
  it('consumes a message and publishes downstream', async () => {
    const fake = new FakeBenzeneMessageSender();
    const host = buildGooglePubSubFunctionHost(
      benzeneTestHost(PublishingStartUp).withServices((services) =>
        services.addSingletonInstance(IBenzeneMessageSender, fake),
      ),
    );

    await host.sendPubSubAsync(asPubSubEvent(messageBuilder('order:place', { id: 'abc', name: 'acme' })));

    expect(fake.lastTopic).toBe('order:created');
    expect(fake.lastRequest).toMatchObject({ id: 'abc', name: 'acme' });
  });
});

asPubSubEvent(...) puts the topic on the "topic" attribute and base64-encodes the body, exactly the shape the transport reads. You can also build the payload directly:

import { PubSubMessageBuilder } from '@benzene/google-cloud-functions-pubsub-testing';

const data = new PubSubMessageBuilder()
  .withTopic('order:place')
  .withBody({ id: 'xyz', name: 'globex' })
  .build();
await host.sendPubSubAsync(data);

See Testing Benzene for the full pattern.

Next steps