@@ -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+
127174export 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