Skip to main content

Request Routing with routeDORequest

The routeDORequest function provides intelligent routing of HTTP and WebSocket requests to Durable Objects with support for authentication hooks and prefix matching. It's a near drop-in replacement for Cloudflare agents package routeAgentRequest and PartyKit's routePartyRequest, but uses standard Cloudflare naming conventions (no "party", "room", "agent") and has many useful upgrades.

Installation

npm install @lumenize/utils

Basic Usage

Use in a Worker's fetch handler:

async fetch(request: Request, env: Env): Promise<Response> {
// Try routing to a Durable Object
const response = await routeDORequest(request, env);
if (response) return response;

// Fallback for non-DO routes
return new Response('Not Found', { status: 404 });
}

URL Format

Requests must follow this format:

[/${prefix}]/${doBindingName}/${doInstanceNameOrId}[/path...]

Instance ID Handling

The function automatically detects 64-character hex strings (from newUniqueId().toString()) and uses idFromString() instead of getByName():

  • Named instance

    • Path: /my-do/lobby
    • Resolves to: env.MY_DO.getByName('lobby')
  • Unique ID (64‑char hex)

    • Path: /my-do/8aa7a69131efa8902661702e701295f168aa5806045ec15d01a2f465bd5f3b99
    • Resolves to: env.MY_DO.get(env.MY_DO.idFromString('8aa7...'))
  • Named instance with prefix

    • Path: /do/user-do/john
    • With config: { prefix: '/do' }
    • Resolves to: env.USER_DO.getByName('john')

Supported Case Conversions for doBindingName

The function intelligently converts URL path segments to match various binding naming conventions, but only for kebab-case URLs (kebab-case-with-only-lowercase-and-digits-3-42). Any path segment containing uppercase letters, underscores, or other characters will match exactly as-is.

Smart matching (kebab-case only):

URL Path SegmentMatches Binding Names
/my-do/...MY_DO, MyDO, MyDo, myDo, my-do
/user-session/...USER_SESSION, UserSession, userSession, user-session
/my-d-o/...MY_D_O, MyDO, myDO, my-d-o
/api-v2/...API_V2, ApiV2, apiV2, api-v2

Exact matching only (non-kebab-case):

URL Path SegmentMatches Binding Names
/MY_DO/...MY_DO only
/MyDO/...MyDO only
/my_do/...my_do only

Hooks

onBeforeRequest

onBeforeRequest runs for HTTP requests before they reach your Durable Object. Use it to:

  • reject requests early by returning a Response (e.g., 401/403),
  • modify the Request (return a new Request), or
  • continue unchanged by returning undefined.

This hook does not run for WebSocket upgrade requests.

onBeforeConnect

onBeforeConnect runs only for WebSocket upgrade requests (Upgrade: websocket). Use it to validate or enrich the request before the upgrade. Return a Response to block, a modified Request to continue with changes, or undefined to proceed as-is.

Examples

Use hooks to validate or modify requests before they reach your Durable Objects:

const response = await routeDORequest(request, env, {
// Hook for WebSocket connections
onBeforeConnect: async (request, { doNamespace, doInstanceNameOrId }) => {
const token = request.headers.get('Authorization');
if (!token || !await validateToken(token)) {
return new Response('Unauthorized', { status: 401 });
}

// Add user info to headers
const modifiedRequest = new Request(request);
modifiedRequest.headers.set('X-User-ID', await getUserId(token));
return modifiedRequest;
},

// Hook for HTTP requests
onBeforeRequest: async (request, { doNamespace, doInstanceNameOrId }) => {
const apiKey = request.headers.get('X-API-Key');
if (request.method !== 'GET' && !apiKey) {
return Response.json(
{ error: 'API key required' },
{ status: 403 }
);
}
}
});

Prefix Matching

Use the prefix option to scope routing to a specific URL path:

const response = await routeDORequest(request, env, { prefix: '/do' });
if (response) return response;

routeDORequest will short-circuit return undefined if the Request.url doesn't match the prefix.

Lumenize's own routeAgentRequest

@lumenize/utils also exports our own routeAgentRequest. It defaults the prefix to '/agents' and adds the same x-partykit-... headers that agents routeAgentRequest does. This is the only place in Lumenize, where we allowed PartyKit foo to creep in and we only did it so it could serve as a near drop in replacement with much better CORS support.

It's still only "near" drop-in. Lumenize's onBeforeConnect and onBeforeRequest hooks don't support some of the optional parameters as agents' because they too have PartyKit foo in them.

CORS Support

The most common cross-origin resource sharing (CORS) configuration and our recommendation 90% of the time is a simple allowlist. This is not supported by agents' routeAgentRequest.

// Allowlist specific origins
await routeDORequest(request, env, {
cors: {
origin: ['https://app.example.com', 'https://admin.example.com']
}
});

The following examples match the behavior of agents' routeAgentRequest using the same configuration but we recommend against their use particularly if your DOs use WebSockets with a browser because the browser does not natively provide CORS protection for WebSocket use.

await routeDORequest(request, env, { cors: true }); // Allow all origins (permissive)
await routeDORequest(request, env ); // Default, only allow `fetch` on this origin
// Note, the default ('false') allows WebSocket access for ALL origins

CORS support goes beyond true, false, and a allowlist. For a detailed discussion and all available options in case you have an advanced use case see CORS Support.

When enabled, CORS support automatically:

  • Reflects the allowed origin in Access-Control-Allow-Origin header
  • Sets Vary: Origin for proper caching
  • Handles OPTIONS (preflight) requests
  • Adds headers to both DO responses and onBefore... hook responses

Multiple Routers

When using multiple routing functions (e.g., routeDORequest and routeAgentRequest), use the idiomatic pattern with || for clean fallthrough. Each router checks its prefix and returns undefined if it doesn't match:

import { routeDORequest, routeAgentRequest } from '@lumenize/utils';

// ...

async fetch(request: Request, env: Env): Promise<Response> {
return (
await routeDORequest(request, env, { prefix: '/do' }) ||
await routeAgentRequest(request, env) || // default prefix is '/agents'
new Response("Not Found", { status: 404 })
);
}

Error Handling

The function throws errors for invalid requests:

  • MissingInstanceNameError (400) - Binding name found but instance name/ID missing
  • MultipleBindingsFoundError (400) - Multiple DO bindings match the path segment (configuration error)

Note, if the url segment for doBindingName doesn't match any DO Namespaces found on env, it will return undefined, not an error.

Each error includes an httpErrorCode property in addition to the standard message property for easy HTTP response handling:

import { routeDORequest, routeAgentRequest } from '@lumenize/utils';

// ...

async fetch(request: Request, env: Env): Promise<Response> {
try {
return (
await routeDORequest(request, env, { prefix: '/do' }) ||
await routeAgentRequest(request, env) || // default prefix is '/agents'
new Response("Not Found", { status: 404 })
);
} catch (error: any) {
// Handle MissingInstanceNameError, MultipleBindingsFoundError, etc.
const status = error.httpErrorCode || 500;
return new Response(error.message, { status });
}
}

Key Features

  • Case-insensitive binding name matching - Flexible naming conventions
  • Automatic ID detection - Handles both named instances and unique IDs
  • Hooks - Authenticate, validate, or enhance requests before they reach DOs
  • Prefix support - Scope routing to specific URL paths
  • Hono-style behavior - Returns undefined if request doesn't match
  • Standard Cloudflare naming - No confusing party/agent/room terminology
  • Robust CORS support - allowlist and custom function in addition to true/false