Skip to content

Service

Service

Overview

Services are stateless HTTP workers that handle requests and responses without maintaining persistent state between invocations. Each service processes requests independently and can be deployed as public endpoints or internal bindings for inter-service communication.

Services excel at API development, webhook handling, and request routing where each invocation should be independent. They receive fresh execution contexts for every request, making them suitable for stateless operations that don’t require persistent memory.

Key benefits include stateless execution model, automatic request routing, inter-service communication through bindings, and public URL access through domain configuration.

Prerequisites

  • Active Raindrop project with manifest configuration
  • Understanding of TypeScript classes and HTTP request/response patterns
  • Familiarity with serverless function concepts
  • Knowledge of REST API design principles

Creating/Getting Started

Define services in your manifest file to create HTTP request handlers:

application "api-app" {
service "user-api" {
visibility = "public"
domain {
fqdn = "api.example.com"
}
}
service "subdomain-api" {
visibility = "public"
domain {
cname = "my-api-v2"
}
}
service "internal-processor" {
visibility = "application"
}
}

Generate the service implementation:

Terminal window
raindrop build generate

This creates the base service class structure:

export default class extends Service<Env> {
async fetch(request: Request): Promise<Response> {
return new Response('Hello World');
}
}

Accessing/Basic Usage

Implement HTTP request handling in the fetch method with full access to request data:

export default class extends Service<Env> {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const method = request.method;
if (method === 'GET' && url.pathname === '/users') {
return this.getUsers();
}
if (method === 'POST' && url.pathname === '/users') {
const userData = await request.json();
return this.createUser(userData);
}
return new Response('Not Found', { status: 404 });
}
private async getUsers(): Promise<Response> {
const users = [{ id: 1, name: 'John Doe' }];
return Response.json(users);
}
private async createUser(userData: any): Promise<Response> {
const newUser = { id: Date.now(), ...userData };
return Response.json(newUser, { status: 201 });
}
}

Access other services through environment bindings:

export default class extends Service<Env> {
async fetch(request: Request): Promise<Response> {
// Call internal service method
const result = await this.env.INTERNAL_PROCESSOR.process(data);
return Response.json(result);
}
}

Core Concepts

Services follow a stateless execution model where each request receives a fresh execution context without persistent state between invocations.

Execution Context: Each service instance receives an ExecutionContext providing access to runtime capabilities and the waitUntil method for background tasks.

Environment Bindings: Services access other resources through the env property containing typed bindings to actors, databases, storage, and other services.

Request Lifecycle: Services process individual HTTP requests through the fetch method, with automatic cleanup after each response.

Service Stubs: Internal services are accessed through typed stubs that provide RPC-style method calls with Promise-wrapped return values.

Service Base Class

The Service<Env> abstract class provides the foundation for all service implementations:

abstract class Service<Env> {
ctx: ExecutionContext;
env: Env;
constructor(ctx: ExecutionContext, env: Env) {
this.ctx = ctx;
this.env = env;
}
abstract fetch(request: Request): Promise<Response>;
}

Properties:

  • ctx: ExecutionContext - Runtime execution context
  • env: Env - Typed environment bindings for resources

Request Handling

Process HTTP requests using the standard Web API Request/Response interfaces with full access to headers, body, and URL parameters.

fetch(request)

Handle incoming HTTP requests with complete request information:

async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const headers = request.headers;
const method = request.method;
// Parse request body for POST/PUT requests
let body = null;
if (method === 'POST' || method === 'PUT') {
body = await request.json();
}
// Route based on path and method
switch (`${method} ${url.pathname}`) {
case 'GET /health':
return new Response('OK');
case 'POST /webhook':
return this.handleWebhook(body, headers);
case 'GET /api/users':
const limit = url.searchParams.get('limit') || '10';
return this.getUsers(parseInt(limit));
default:
return new Response('Not Found', { status: 404 });
}
}

Parameters:

  • request: Request - Standard Web API Request object

Returns: Promise<Response> - Standard Web API Response object

Request Processing Patterns

Handle common HTTP patterns with proper error handling and response formatting:

// JSON API responses
private async handleApiRequest(request: Request): Promise<Response> {
try {
const data = await this.processRequest(request);
return Response.json({
success: true,
data
});
} catch (error) {
return Response.json({
success: false,
error: error.message
}, { status: 500 });
}
}
// File upload handling
private async handleFileUpload(request: Request): Promise<Response> {
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return new Response('No file provided', { status: 400 });
}
const buffer = await file.arrayBuffer();
// Process file buffer...
return Response.json({
filename: file.name,
size: buffer.byteLength
});
}
// Webhook signature verification
private async verifyWebhook(request: Request): Promise<boolean> {
const signature = request.headers.get('X-Signature');
const body = await request.text();
// Verify signature logic...
return signature === expectedSignature;
}

Visibility Configuration

Configure service access levels using the visibility parameter to control deployment behavior and routing.

Visibility Options

Services support these visibility levels:

  • public: Accessible on the internet without authentication
  • protected: Requires authentication via authorization server
  • private: Accessible within private network only
  • application: Internal to application, accessible via service bindings
  • none: No external routing, internal use only
  • suite: Suite-level access (specialized use)
  • tenant: Tenant-specific access (specialized use)

Development Best Practices

Use visibility = "public" during development to receive generated URLs:

service "dev-api" {
visibility = "public"
# Check generated URL with: raindrop build status
}

Check the generated URL after deployment:

Terminal window
raindrop build status

This approach avoids CNAME binding conflicts that cause deployment issues.

Production Configuration

Reserve custom domains for production services with static address requirements:

service "prod-api" {
visibility = "public"
domain {
fqdn = "api.mycompany.com"
}
}

Domain Configuration

Services can be configured with custom domains for external access using either fully qualified domain names (FQDN) or auto-generated subdomains (CNAME).

Domain Block Structure

Define domains within a domain block in your service configuration:

service "my-service" {
domain {
fqdn = "api.mycompany.com" # Custom domain
}
}
# OR
service "my-service" {
domain {
cname = "my-unique-subdomain" # Auto-generated subdomain
}
}

Configuration Requirements:

  • Each domain block must specify either cname OR fqdn (but not both)
  • fqdn: Fully qualified domain name (e.g., “api.example.com”)
  • cname: Subdomain prefix that generates “<cname>.<org-id>.lmapp.run

Domain Validation Rules

Domain configurations are validated with these constraints:

FQDN Validation:

  • Must be a valid domain name format
  • Maximum length: 63 characters
  • Pattern: /^[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,6}$/

CNAME Validation:

  • Must be a valid subdomain prefix
  • Maximum length: ~35 characters (63 minus platform suffix)
  • Pattern validates as subdomain format

Domain Configuration Examples

application "multi-domain-app" {
# Production API with custom domain
service "api" {
visibility = "public"
domain {
fqdn = "api.mycompany.com"
}
}
# Development API with auto-generated subdomain
service "dev-api" {
visibility = "public"
domain {
cname = "dev-api-v2"
}
# Results in: dev-api-v2.org_abc123.lmapp.run
}
# Multiple services with different domains
service "admin" {
visibility = "protected"
domain {
fqdn = "admin.mycompany.com"
}
}
service "webhooks" {
visibility = "public"
domain {
cname = "webhooks-prod"
}
}
}

Service Bindings

Call methods on other services within your application using typed stubs that provide RPC-style communication.

Internal Service Communication

Services without domain configuration are internal and accessed through bindings:

application "microservices" {
service "api-gateway" {
visibility = "public"
domain {
fqdn = "api.example.com"
}
}
service "user-service" {
visibility = "application"
}
service "order-service" {
visibility = "application"
}
}

Key Requirements:

  • All service binding calls must be awaited
  • Internal services still require a fetch method implementation
  • Custom methods can be called through bindings, but not fetch
  • Return values are automatically wrapped in Promises

ServiceStub Type

Service bindings are typed as ServiceStub<T> providing type safety for method calls:

type ServiceStub<T extends Service<Env>, Env = unknown> = Stub<T>;
// Stub converts methods to Promise-wrapped versions
type Stub<T> = {
[P in keyof T as T[P] extends (...args: any[]) => any ? P : never]: T[P] extends (...args: infer A) => infer R
? (...args: A) => Promise<Awaited<R>>
: never;
};

Background Tasks

Use the execution context to schedule background work that continues after response is sent.

ctx.waitUntil(promise)

Schedule asynchronous work to complete after the response:

async fetch(request: Request): Promise<Response> {
// Send immediate response
const response = Response.json({ status: 'received' });
// Schedule background processing
this.ctx.waitUntil(
this.processAsync(request)
);
return response;
}
private async processAsync(request: Request): Promise<void> {
// This continues running after response is sent
const data = await request.json();
await this.sendNotification(data);
await this.updateAnalytics(data);
}

Parameters:

  • promise: Promise<any> - Asynchronous work to complete

Code Examples

Complete service implementations demonstrating common patterns and use cases.

REST API Service

application "todo-api" {
service "api" {
visibility = "public"
domain {
fqdn = "todos.example.com"
}
}
sql_database "todos" {}
}
export default class extends Service<Env> {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const method = request.method;
// Enable CORS for browser requests
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
'Access-Control-Allow-Headers': 'Content-Type'
};
if (method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
try {
const response = await this.routeRequest(method, url);
// Add CORS headers to all responses
Object.entries(corsHeaders).forEach(([key, value]) => {
response.headers.set(key, value);
});
return response;
} catch (error) {
return Response.json(
{ error: error.message },
{ status: 500, headers: corsHeaders }
);
}
}
private async routeRequest(method: string, url: URL): Promise<Response> {
const path = url.pathname;
const id = path.split('/todos/')[1];
switch (`${method} ${path}`) {
case 'GET /todos':
return this.getAllTodos();
case 'POST /todos':
return this.createTodo(request);
case `GET /todos/${id}`:
return this.getTodo(id);
case `PUT /todos/${id}`:
return this.updateTodo(id, request);
case `DELETE /todos/${id}`:
return this.deleteTodo(id);
default:
return new Response('Not Found', { status: 404 });
}
}
private async getAllTodos(): Promise<Response> {
const result = await this.env.TODOS.prepare(
'SELECT * FROM todos ORDER BY created_at DESC'
).all();
return Response.json(result.results);
}
private async createTodo(request: Request): Promise<Response> {
const { title, description } = await request.json();
if (!title) {
return Response.json(
{ error: 'Title is required' },
{ status: 400 }
);
}
const result = await this.env.TODOS.prepare(
'INSERT INTO todos (title, description, completed) VALUES (?, ?, ?) RETURNING *'
).bind(title, description, false).first();
return Response.json(result, { status: 201 });
}
private async getTodo(id: string): Promise<Response> {
const todo = await this.env.TODOS.prepare(
'SELECT * FROM todos WHERE id = ?'
).bind(id).first();
if (!todo) {
return new Response('Todo not found', { status: 404 });
}
return Response.json(todo);
}
private async updateTodo(id: string, request: Request): Promise<Response> {
const updates = await request.json();
const result = await this.env.TODOS.prepare(
'UPDATE todos SET title = ?, description = ?, completed = ? WHERE id = ? RETURNING *'
).bind(
updates.title,
updates.description,
updates.completed,
id
).first();
if (!result) {
return new Response('Todo not found', { status: 404 });
}
return Response.json(result);
}
private async deleteTodo(id: string): Promise<Response> {
const result = await this.env.TODOS.prepare(
'DELETE FROM todos WHERE id = ? RETURNING id'
).bind(id).first();
if (!result) {
return new Response('Todo not found', { status: 404 });
}
return Response.json({ deleted: true });
}
}

Webhook Processing Service

export default class extends Service<Env> {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/webhook/stripe') {
return this.handleStripeWebhook(request);
}
if (url.pathname === '/webhook/github') {
return this.handleGitHubWebhook(request);
}
return new Response('Unknown webhook', { status: 404 });
}
private async handleStripeWebhook(request: Request): Promise<Response> {
const signature = request.headers.get('stripe-signature');
const body = await request.text();
// Verify webhook signature
if (!this.verifyStripeSignature(body, signature)) {
return new Response('Invalid signature', { status: 401 });
}
const event = JSON.parse(body);
// Process webhook asynchronously
this.ctx.waitUntil(
this.processStripeEvent(event)
);
return Response.json({ received: true });
}
private async processStripeEvent(event: any): Promise<void> {
switch (event.type) {
case 'payment_intent.succeeded':
await this.handlePaymentSuccess(event.data.object);
break;
case 'customer.subscription.deleted':
await this.handleSubscriptionCanceled(event.data.object);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
}
private async handlePaymentSuccess(paymentIntent: any): Promise<void> {
// Update database, send notifications, etc.
await this.env.ORDERS.prepare(
'UPDATE orders SET status = ? WHERE payment_intent_id = ?'
).bind('paid', paymentIntent.id).run();
}
private verifyStripeSignature(body: string, signature: string | null): boolean {
// Implement Stripe webhook signature verification
return signature !== null; // Simplified
}
}
## raindrop.manifest
Configure HTTP services in your manifest for request handling and API endpoints:
```hcl
application "web-app" {
service "api" {
visibility = "public"
domain {
fqdn = "api.example.com"
}
# Public HTTP service accessible via custom domain
}
service "admin-api" {
visibility = "protected"
domain {
cname = "admin-v2"
}
# Admin interface with auto-generated subdomain
}
service "webhook-handler" {
visibility = "public"
domain {
fqdn = "webhooks.example.com"
}
# Service for handling external webhooks
}
service "internal-processor" {
visibility = "application"
# Internal service accessible only via bindings
# No domain = not publicly accessible
}
actor "data-processor" {
# Services can communicate with actors
}
kv "session-cache" {
# Services can use other resources
}
}