Serverless

Azure Functions

Register your API as an Azure Functions v4 HTTP trigger.

On this page

@hulla/api-azure-functions turns your implementation into an Azure HTTP handler. Register it with app.http() in an Azure Functions Node.js v4 project.

This guide assumes an Azure Functions v4 project already exists. The example uses Zod for its request and response schemas.

Declare the HTTP path shared by server and client code. The contract includes /api because Azure’s default route prefix remains in the URL received by the handler:

TypeScript
// src/api/contract.ts
import { defineContract, response, route } from '@hulla/api'
import { z } from 'zod'

export const contract = defineContract({
  basePath: '/api',
  routes: {
    profile: route.get('/profiles/:id', {
      params: z.object({ id: z.string() }),
      responses: {
        200: response.json(z.object({ id: z.string(), runtime: z.string() })),
      },
    }),
  },
})

Implement the route and register the mounted handler. app.http() returns void, so export the handler itself when another module needs to invoke the adapter boundary directly:

TypeScript
// src/functions/api.ts
import { app } from '@azure/functions'
import { azureFunctionsAdapter } from '@hulla/api-azure-functions'
import { defineServer } from '@hulla/api/server'
import { contract } from '../api/contract'

const implementation = defineServer(contract).implement({
  profile: ({ params, response }) =>
    response(200, {
      id: params.id,
      runtime: 'azure-functions',
    }),
})

export const handler = azureFunctionsAdapter().mount(implementation)

app.http('api', {
  authLevel: 'anonymous',
  methods: ['GET'],
  route: '{*path}',
  handler,
})

The catch-all trigger lets one function serve every route in the contract. List every HTTP method used by the contract in methods; Azure rejects an unlisted method before the request reaches @hulla/api.

Invoke the exported handler with Azure’s own request and invocation types to verify the integration without deploying:

TypeScript
// scripts/verify-azure.ts
import { HttpRequest, InvocationContext } from '@azure/functions'
import { handler } from '../src/functions/api'

const result = await handler(
  new HttpRequest({
    method: 'GET',
    url: 'https://example.azurewebsites.net/api/profiles/user-1',
  }),
  new InvocationContext({
    functionName: 'api',
    invocationId: 'local-1',
  })
)

console.log(result)
// {
//   status: 200,
//   headers: { 'content-type': 'application/json' },
//   jsonBody: { id: 'user-1', runtime: 'azure-functions' }
// }

The handler returns HttpResponseInit | HttpResponse, not a Web Response. Azure turns that native value into the public HTTP response.

For each invocation:

  1. The Azure host selects a function using its route, allowed methods, and authorization settings.
  2. Azure calls the registered handler with HttpRequest and InvocationContext values.
  3. The adapter reads the method and full URL pathname, then matches a route in the contract.
  4. It decodes and validates the declared query, headers, and body before running context, middleware, and the route handler.
  5. It validates the declared result and translates it to HttpResponseInit | HttpResponse.

An unmatched contract path returns 404; a known path with the wrong method returns 405 only when Azure’s trigger allows that method through. Trigger routing and authorization failures happen before the adapter runs.

Azure prefixes HTTP trigger routes with api by default. That prefix is present in HttpRequest.url, so it must also be present in the path matched by the contract.

host.json route prefixTrigger routePublic pathMatching contract path
api (default){*path}/api/profiles/user-1/api/profiles/:id
""{*path}/profiles/user-1/profiles/:id

Configure a different prefix with extensions.http.routePrefix in host.json, then make the contract basePath match the pathname Azure passes to the handler. Microsoft documents this host setting in Customize an HTTP endpoint.

Do not add the host prefix twice. With the default Azure prefix and a contract basePath of /api, the client calls https://example.azurewebsites.net/api/...; it does not add another /api outside the contract path.

Use adapter.context() when handlers need Azure’s invocation ID, logger, authenticated user, or another value from the native request. Define the context and mount the implementation through the same adapter:

TypeScript
// src/functions/api.ts
import { app } from '@azure/functions'
import { azureFunctionsAdapter } from '@hulla/api-azure-functions'
import { defineServer } from '@hulla/api/server'
import { contract } from '../api/contract'

const adapter = azureFunctionsAdapter()

const implementation = defineServer(contract, {
  context: adapter.context(({ request, invocationContext, route }) => ({
    invocationId: invocationContext.invocationId,
    user: request.user,
    routeKey: route.key.join('.'),
  })),
}).implement({
  profile: ({ params, context, response }) => {
    console.info(context.invocationId, context.routeKey, context.user)
    return response(200, {
      id: params.id,
      runtime: 'azure-functions',
    })
  },
})

export const handler = adapter.mount(implementation)

app.http('api', {
  authLevel: 'anonymous',
  methods: ['GET'],
  route: '{*path}',
  handler,
})

An adapter-native context binds the implementation to the Azure Functions adapter. Mounting it through another adapter fails during setup rather than supplying incompatible native state.

This adapter does not receive a client-disconnect signal from the Azure handler. The portable signal therefore does not abort when the caller disconnects; application work that needs cancellation must use an Azure-appropriate policy.

The adapter reads request bodies through the methods on Azure’s HttpRequest. The declaration’s content type selects the corresponding representation before validation.

Contract representationAzure Functions adapter behavior
EmptyReturns status and headers without a body.
JSONReads request.json() and returns JSON with jsonBody.
TextReads request.text() and returns a string in body.
BytesReads request.arrayBuffer() and returns Uint8Array in body.
Form dataReads request.formData() and returns FormData in body.
Stream responseReturns AsyncIterable<Uint8Array> in body without collecting its chunks in the adapter.
Raw responsePasses through an Azure-native response only when its status matches the declared contract status.

There is no stream or raw request-body representation, and the adapter does not expose a body-size option. Azure host and platform limits apply before or around the handler.

Returning an async iterable is only one half of HTTP streaming. Enable streaming before registering functions when the deployed host must send chunks incrementally:

TypeScript
import { app } from '@azure/functions'

app.setup({ enableHttpStream: true })

Azure HTTP streaming requires the Node.js v4 programming model and compatible Functions host, Core Tools, and @azure/functions versions. Check Microsoft’s current HTTP streaming requirements before relying on incremental delivery. A failure produced while a stream is being consumed happens after the handler has returned and is not handled by the mount-level onError hook.

Pass onError to mount() to record failures with both the request and invocation state:

TypeScript
// src/functions/api.ts
export const handler = adapter.mount(implementation, {
  onError({ error, phase, request, invocationContext, defaultResponse }) {
    invocationContext.error({
      error,
      phase,
      requestUrl: request.url,
      invocationId: invocationContext.invocationId,
    })
    return defaultResponse
  },
})

The hook receives the protocol-safe default Azure response. Return it or undefined to keep the fallback, or return another HttpResponseInit | HttpResponse with a numeric status to replace it. If the hook throws or returns an invalid replacement, the adapter keeps the default response without invoking the hook again.

Use declared contract responses for expected application outcomes. onError is for failures while matching routes, decoding or validating inputs, creating context, running middleware or handlers, validating outputs, or translating the Azure response.

Timer, queue, blob, Event Hub, and other triggers are application events rather than @hulla/api HTTP requests. Call the underlying application service directly from those functions instead of passing their payloads to this adapter.

The adapter returns an HTTP handler. It does not create the function app, select a Node.js runtime, register the trigger, configure authorization or bindings, change host.json, or deploy infrastructure.