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')
- Path:
-
Unique ID (64‑char hex)
- Path:
/my-do/8aa7a69131efa8902661702e701295f168aa5806045ec15d01a2f465bd5f3b99
- Resolves to:
env.MY_DO.get(env.MY_DO.idFromString('8aa7...'))
- Path:
-
Named instance with prefix
- Path:
/do/user-do/john
- With config:
{ prefix: '/do' }
- Resolves to:
env.USER_DO.getByName('john')
- Path:
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 Segment | Matches 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 Segment | Matches 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 newRequest
), 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 missingMultipleBindingsFoundError
(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