Skip to content

Commit 9bd3f56

Browse files
committed
Add CORS support to routePartykitRequest.
Pass `cors: true` for permissive defaults or `cors: { ...headers }` for custom CORS headers. Preflight (OPTIONS) requests are handled automatically for matched routes, and CORS headers are appended to all non-WebSocket responses — including responses returned by `onBeforeRequest`.
1 parent 7b2520c commit 9bd3f56

12 files changed

Lines changed: 275 additions & 158 deletions

File tree

.changeset/cors-support.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"partyserver": patch
3+
---
4+
5+
Add CORS support to `routePartykitRequest`.
6+
7+
Pass `cors: true` for permissive defaults or `cors: { ...headers }` for custom CORS headers. Preflight (OPTIONS) requests are handled automatically for matched routes, and CORS headers are appended to all non-WebSocket responses — including responses returned by `onBeforeRequest`.

package-lock.json

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/hono-party/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,6 @@
3737
"devDependencies": {
3838
"@cloudflare/workers-types": "^4.20251218.0",
3939
"hono": "^4.11.1",
40-
"partyserver": "^0.1.2"
40+
"partyserver": "^0.1.3"
4141
}
4242
}

packages/partyfn/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
],
2020
"dependencies": {
2121
"nanoid": "^5.1.6",
22-
"partysocket": "^1.1.11"
22+
"partysocket": "^1.1.12"
2323
},
2424
"scripts": {
2525
"build": "tsx scripts/build.ts"

packages/partyserver/src/index.ts

Lines changed: 71 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,30 @@ export interface PartyServerOptions<
102102
jurisdiction?: DurableObjectJurisdiction;
103103
locationHint?: DurableObjectLocationHint;
104104
props?: Props;
105+
/**
106+
* Whether to enable CORS for matched routes.
107+
*
108+
* When `true`, uses default permissive CORS headers:
109+
* - Access-Control-Allow-Origin: *
110+
* - Access-Control-Allow-Methods: GET, POST, HEAD, OPTIONS
111+
* - Access-Control-Allow-Headers: *
112+
* - Access-Control-Max-Age: 86400
113+
*
114+
* For credentialed requests, pass explicit headers with a specific origin:
115+
* ```ts
116+
* cors: {
117+
* "Access-Control-Allow-Origin": "https://myapp.com",
118+
* "Access-Control-Allow-Credentials": "true",
119+
* "Access-Control-Allow-Methods": "GET, POST, HEAD, OPTIONS",
120+
* "Access-Control-Allow-Headers": "Content-Type, Authorization"
121+
* }
122+
* ```
123+
*
124+
* When set to a `HeadersInit` value, uses those as the CORS headers instead.
125+
* CORS preflight (OPTIONS) requests are handled automatically for matched routes.
126+
* Non-WebSocket responses on matched routes will also have the CORS headers appended.
127+
*/
128+
cors?: boolean | HeadersInit;
105129
onBeforeConnect?: (
106130
req: Request,
107131
lobby: {
@@ -122,8 +146,31 @@ export interface PartyServerOptions<
122146
| Promise<Response | Request | undefined | void>;
123147
}
124148
/**
125-
* A utility function for PartyKit style routing.
149+
* Resolve CORS options into a concrete headers object (or null if CORS is disabled).
126150
*/
151+
function resolveCorsHeaders(
152+
cors: boolean | HeadersInit | undefined
153+
): Record<string, string> | null {
154+
if (cors === true) {
155+
return {
156+
"Access-Control-Allow-Origin": "*",
157+
"Access-Control-Allow-Methods": "GET, POST, HEAD, OPTIONS",
158+
"Access-Control-Allow-Headers": "*",
159+
"Access-Control-Max-Age": "86400"
160+
};
161+
}
162+
if (cors && typeof cors === "object") {
163+
// Convert any HeadersInit shape to a plain record
164+
const h = new Headers(cors as HeadersInit);
165+
const record: Record<string, string> = {};
166+
h.forEach((value, key) => {
167+
record[key] = value;
168+
});
169+
return record;
170+
}
171+
return null;
172+
}
173+
127174
export async function routePartykitRequest<
128175
Env extends Cloudflare.Env = Cloudflare.Env,
129176
T extends Server<Env> = Server<Env>,
@@ -188,6 +235,26 @@ Did you forget to add a durable object binding to the class ${namespace[0].toUpp
188235
return new Response("Invalid request", { status: 400 });
189236
}
190237

238+
// Resolve CORS headers for this matched route
239+
const corsHeaders = resolveCorsHeaders(options?.cors);
240+
const isWebSocket =
241+
req.headers.get("Upgrade")?.toLowerCase() === "websocket";
242+
243+
// Helper: append CORS headers to a response (skipped for WebSocket upgrades)
244+
function withCorsHeaders(response: Response): Response {
245+
if (!corsHeaders || isWebSocket) return response;
246+
const newResponse = new Response(response.body, response);
247+
for (const [key, value] of Object.entries(corsHeaders)) {
248+
newResponse.headers.set(key, value);
249+
}
250+
return newResponse;
251+
}
252+
253+
// Handle CORS preflight requests for matched routes
254+
if (req.method === "OPTIONS" && corsHeaders) {
255+
return new Response(null, { headers: corsHeaders });
256+
}
257+
191258
let doNamespace = map[namespace];
192259
if (options?.jurisdiction) {
193260
doNamespace = doNamespace.jurisdiction(options.jurisdiction);
@@ -210,7 +277,7 @@ Did you forget to add a durable object binding to the class ${namespace[0].toUpp
210277
req.headers.set("x-partykit-props", JSON.stringify(options?.props));
211278
}
212279

213-
if (req.headers.get("Upgrade")?.toLowerCase() === "websocket") {
280+
if (isWebSocket) {
214281
if (options?.onBeforeConnect) {
215282
const reqOrRes = await options.onBeforeConnect(req, {
216283
party: namespace,
@@ -231,12 +298,12 @@ Did you forget to add a durable object binding to the class ${namespace[0].toUpp
231298
if (reqOrRes instanceof Request) {
232299
req = reqOrRes;
233300
} else if (reqOrRes instanceof Response) {
234-
return reqOrRes;
301+
return withCorsHeaders(reqOrRes);
235302
}
236303
}
237304
}
238305

239-
return stub.fetch(req);
306+
return withCorsHeaders(await stub.fetch(req));
240307
} else {
241308
return null;
242309
}
@@ -336,17 +403,6 @@ Did you try connecting directly to this Durable Object? Try using getServerByNam
336403
return Response.json({ ok: true });
337404
}
338405

339-
// Handle keep-alive WebSocket endpoint (internal use for waitUntil)
340-
if (url.pathname === "/cdn-cgi/partyserver/keep-alive/") {
341-
if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") {
342-
const { 0: client, 1: server } = new WebSocketPair();
343-
// Always use hibernation API for keep-alive (efficient, internal-only)
344-
this.ctx.acceptWebSocket(server, ["partyserver-keepalive"]);
345-
return new Response(null, { status: 101, webSocket: client });
346-
}
347-
return new Response("WebSocket required", { status: 426 });
348-
}
349-
350406
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
351407
return await this.onRequest(request);
352408
} else {
@@ -414,15 +470,6 @@ Did you try connecting directly to this Durable Object? Try using getServerByNam
414470
}
415471

416472
async webSocketMessage(ws: WebSocket, message: WSMessage): Promise<void> {
417-
// Handle keep-alive pings first (internal waitUntil mechanism)
418-
const tags = this.ctx.getTags(ws);
419-
if (tags.includes("partyserver-keepalive")) {
420-
if (message === "ping") {
421-
ws.send("pong");
422-
}
423-
return;
424-
}
425-
426473
// Ignore websockets accepted outside PartyServer (e.g. via
427474
// `state.acceptWebSocket()` in user code). These sockets won't have the
428475
// `__pk` attachment namespace required to rehydrate a Connection.
@@ -451,12 +498,6 @@ Did you try connecting directly to this Durable Object? Try using getServerByNam
451498
reason: string,
452499
wasClean: boolean
453500
): Promise<void> {
454-
// Ignore keep-alive socket closes (internal waitUntil mechanism)
455-
const tags = this.ctx.getTags(ws);
456-
if (tags.includes("partyserver-keepalive")) {
457-
return;
458-
}
459-
460501
if (!isPartyServerWebSocket(ws)) {
461502
return;
462503
}
@@ -476,12 +517,6 @@ Did you try connecting directly to this Durable Object? Try using getServerByNam
476517
}
477518

478519
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
479-
// Ignore keep-alive socket errors (internal waitUntil mechanism)
480-
const tags = this.ctx.getTags(ws);
481-
if (tags.includes("partyserver-keepalive")) {
482-
return;
483-
}
484-
485520
if (!isPartyServerWebSocket(ws)) {
486521
return;
487522
}
@@ -630,114 +665,6 @@ Did you try connecting directly to this Durable Object? Try using getServerByNam
630665
return [];
631666
}
632667

633-
/**
634-
* Execute a long-running async function while keeping the Durable Object alive.
635-
*
636-
* Durable Objects normally terminate 70-140s after the last network request.
637-
* This method keeps the DO alive by establishing a WebSocket connection to itself
638-
* and sending periodic ping messages.
639-
*
640-
* @experimental This API is experimental and may change in future versions.
641-
*
642-
* @param fn - The async function to execute
643-
* @param timeoutMs - Maximum time to keep the DO alive (default: 30 minutes)
644-
* @returns The result of the async function
645-
*
646-
* @remarks
647-
* Requires the `enable_ctx_exports` compatibility flag in wrangler.jsonc:
648-
* ```json
649-
* {
650-
* "compatibility_flags": ["enable_ctx_exports"]
651-
* }
652-
* ```
653-
*
654-
* @example
655-
* ```typescript
656-
* const result = await this.experimental_waitUntil(async () => {
657-
* // Long-running operation
658-
* await processLargeDataset();
659-
* return { success: true };
660-
* }, 60 * 60 * 1000); // 1 hour timeout
661-
* ```
662-
*/
663-
async experimental_waitUntil<T>(
664-
fn: () => Promise<T>,
665-
timeoutMs: number = 30 * 60 * 1000 // 30 minutes default
666-
): Promise<T> {
667-
// Get namespace from ctx.exports (requires enable_ctx_exports compatibility flag)
668-
const exports = (
669-
this.ctx as DurableObjectState & { exports?: Record<string, unknown> }
670-
).exports;
671-
if (!exports) {
672-
throw new Error(
673-
"waitUntil requires the 'enable_ctx_exports' compatibility flag. " +
674-
'Add it to your wrangler.jsonc: { "compatibility_flags": ["enable_ctx_exports"] }'
675-
);
676-
}
677-
678-
const namespace = exports[this.#ParentClass.name] as
679-
| DurableObjectNamespace
680-
| undefined;
681-
if (!namespace) {
682-
throw new Error(
683-
`Could not find namespace for ${this.#ParentClass.name} in ctx.exports. ` +
684-
"Make sure the class name matches your Durable Object binding."
685-
);
686-
}
687-
688-
const stub = namespace.get(this.ctx.id);
689-
690-
// Connect to self via WebSocket for keep-alive
691-
const response = await stub.fetch(
692-
"http://dummy-example.cloudflare.com/cdn-cgi/partyserver/keep-alive/",
693-
{
694-
headers: {
695-
Upgrade: "websocket",
696-
"x-partykit-room": this.name
697-
}
698-
}
699-
);
700-
701-
const ws = response.webSocket;
702-
if (!ws) {
703-
throw new Error("Failed to establish keep-alive WebSocket connection");
704-
}
705-
ws.accept();
706-
707-
// Set up ping interval (every 10 seconds)
708-
const pingInterval = setInterval(() => {
709-
try {
710-
ws.send("ping");
711-
} catch {
712-
// WebSocket may have closed, ignore
713-
}
714-
}, 10_000);
715-
716-
// Create a timeout promise that rejects after timeoutMs
717-
let timeoutId: ReturnType<typeof setTimeout>;
718-
const timeoutPromise = new Promise<never>((_, reject) => {
719-
timeoutId = setTimeout(() => {
720-
reject(
721-
new Error(`experimental_waitUntil timed out after ${timeoutMs}ms`)
722-
);
723-
}, timeoutMs);
724-
});
725-
726-
try {
727-
// Race the function against the timeout
728-
const result = await Promise.race([fn(), timeoutPromise]);
729-
return result;
730-
} finally {
731-
clearTimeout(timeoutId!);
732-
clearInterval(pingInterval);
733-
try {
734-
ws.close(1000, "Complete");
735-
} catch {
736-
// Ignore close errors
737-
}
738-
}
739-
}
740-
741668
#_props?: Props;
742669

743670
// Implemented by the user

0 commit comments

Comments
 (0)