diff --git a/.changeset/late-hornets-walk.md b/.changeset/late-hornets-walk.md new file mode 100644 index 00000000..904cff53 --- /dev/null +++ b/.changeset/late-hornets-walk.md @@ -0,0 +1,9 @@ +--- +"partyserver": patch +--- + +Add experimental waitUntil API for long-running tasks + +Introduces an internal keep-alive WebSocket endpoint and the experimental_waitUntil method to allow Durable Objects to remain alive while executing long-running async functions. This mechanism uses a self-connecting WebSocket with periodic pings and requires the 'enable_ctx_exports' compatibility flag. Additional handling is added to ignore keep-alive sockets in WebSocket event methods. + +Based on @eastlondoner's https://github.com/eastlondoner/better-wait-until diff --git a/package-lock.json b/package-lock.json index 53ae5b2c..89eec568 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,6 +76,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -113,6 +114,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -150,6 +152,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -183,6 +186,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -263,6 +267,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -299,6 +304,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -337,6 +343,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -1022,6 +1029,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -1266,6 +1274,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2400,7 +2409,8 @@ "version": "4.20250924.0", "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20250924.0.tgz", "integrity": "sha512-pi/OYCroYdwjFWbkciC5oYzlyimDF4ymNotDK0zpLNq91Ogz1IXnVBAYV7fCFAJ/zIxU0RiIBrJIOll/C0pR9Q==", - "license": "MIT OR Apache-2.0" + "license": "MIT OR Apache-2.0", + "peer": true }, "node_modules/@colors/colors": { "version": "1.5.0", @@ -2525,6 +2535,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2571,6 +2582,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -6047,6 +6059,7 @@ "resolved": "https://registry.npmjs.org/@remix-run/react/-/react-2.17.1.tgz", "integrity": "sha512-5MqRK2Z5gkQMDqGfjXSACf/HzvOA+5ug9kiSqaPpK9NX0OF4NlS+cAPKXQWuzc2iLSp6r1RGu8FU1jpZbhsaug==", "license": "MIT", + "peer": true, "dependencies": { "@remix-run/router": "1.23.0", "@remix-run/server-runtime": "2.17.1", @@ -7055,6 +7068,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.11.5.tgz", "integrity": "sha512-jb0KTdUJaJY53JaN7ooY3XAxHQNoMYti/H6ANo707PsLXVeEqJ9o8+eBup1JU5CuwzrgnDc2dECt2WIGX9f8Jw==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -7413,6 +7427,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.11.5.tgz", "integrity": "sha512-z9JFtqc5ZOsdQLd9vRnXfTCQ8v5ADAfRt9Nm7SqP6FUHII8E1hs38ACzf5xursmth/VonJYb5+73Pqxk1hGIPw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.2.1", "prosemirror-collab": "^1.3.1", @@ -8173,6 +8188,7 @@ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", @@ -8188,6 +8204,7 @@ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", @@ -8272,6 +8289,7 @@ "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8687,6 +8705,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -10135,6 +10154,7 @@ "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -12254,7 +12274,6 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", - "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -12346,6 +12365,7 @@ "integrity": "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -13703,6 +13723,7 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.53.0.tgz", "integrity": "sha512-0WNThgC6CMWNXXBxTbaYYcunj08iB5rnx4/G56UOPeL9UVIUGGHA1GR0EWIh9Ebabj7NpCRawQ5b0hfN1jQmYQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/trusted-types": "^1.0.6" } @@ -14496,6 +14517,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -14689,6 +14711,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -14885,6 +14908,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.22.2.tgz", "integrity": "sha512-I4lS7HHIW47D0Xv/gWmi4iUWcQIDYaJKd8Hk4+lcSps+553FlQrhmxtItpEvTr75iAruhzVShVp6WUwsT6Boww==", "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -14914,6 +14938,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.3.tgz", "integrity": "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -14962,6 +14987,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.38.1.tgz", "integrity": "sha512-4FH/uM1A4PNyrxXbD+RAbAsf0d/mM0D/wAKSVVWK7o0A9Q/oOXJBrw786mBf2Vnrs/Edly6dH6Z2gsb7zWwaUw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -15236,7 +15262,6 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -17150,6 +17175,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17417,6 +17443,7 @@ "integrity": "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -17457,6 +17484,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17501,6 +17529,7 @@ "integrity": "sha512-Wj7/AMtE9MRnAXa6Su3Lk0LNCfqDYgfwVjwRFVum9U7wsto1imuHqk4kTm7Jni+5A0Hn7dttL6O/zjvUvoo+8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.7", @@ -17926,6 +17955,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz", "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -18040,6 +18070,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -18053,6 +18084,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -18301,6 +18333,7 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -18321,6 +18354,7 @@ "integrity": "sha512-HCPNUz599h9emi6qjppn92kxS6E12QvD0A3K087CZFEysw6lO1saZUdJ8azk0LsYGYDnrkBs5TzUOEfpuccwWA==", "dev": true, "license": "MIT OR Apache-2.0", + "peer": true, "dependencies": { "@cloudflare/kv-asset-handler": "0.4.0", "@cloudflare/unenv-preset": "2.7.4", @@ -18558,6 +18592,7 @@ "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", "license": "MIT", + "peer": true, "dependencies": { "lib0": "^0.2.85" }, @@ -18659,6 +18694,7 @@ "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz", "integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==", "license": "MIT", + "peer": true, "dependencies": { "lib0": "^0.2.99" }, diff --git a/packages/partyserver/src/index.ts b/packages/partyserver/src/index.ts index bd7e04a5..4ba6b4f3 100644 --- a/packages/partyserver/src/index.ts +++ b/packages/partyserver/src/index.ts @@ -330,6 +330,17 @@ Did you try connecting directly to this Durable Object? Try using getServerByNam return Response.json({ ok: true }); } + // Handle keep-alive WebSocket endpoint (internal use for waitUntil) + if (url.pathname === "/cdn-cgi/partyserver/keep-alive/") { + if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") { + const { 0: client, 1: server } = new WebSocketPair(); + // Always use hibernation API for keep-alive (efficient, internal-only) + this.ctx.acceptWebSocket(server, ["partyserver-keepalive"]); + return new Response(null, { status: 101, webSocket: client }); + } + return new Response("WebSocket required", { status: 426 }); + } + if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") { return await this.onRequest(request); } else { @@ -397,6 +408,15 @@ Did you try connecting directly to this Durable Object? Try using getServerByNam } async webSocketMessage(ws: WebSocket, message: WSMessage): Promise { + // Handle keep-alive pings first (internal waitUntil mechanism) + const tags = this.ctx.getTags(ws); + if (tags.includes("partyserver-keepalive")) { + if (message === "ping") { + ws.send("pong"); + } + return; + } + const connection = createLazyConnection(ws); // rehydrate the server name if it's woken up @@ -418,6 +438,12 @@ Did you try connecting directly to this Durable Object? Try using getServerByNam reason: string, wasClean: boolean ): Promise { + // Ignore keep-alive socket closes (internal waitUntil mechanism) + const tags = this.ctx.getTags(ws); + if (tags.includes("partyserver-keepalive")) { + return; + } + const connection = createLazyConnection(ws); // rehydrate the server name if it's woken up @@ -433,6 +459,12 @@ Did you try connecting directly to this Durable Object? Try using getServerByNam } async webSocketError(ws: WebSocket, error: unknown): Promise { + // Ignore keep-alive socket errors (internal waitUntil mechanism) + const tags = this.ctx.getTags(ws); + if (tags.includes("partyserver-keepalive")) { + return; + } + const connection = createLazyConnection(ws); // rehydrate the server name if it's woken up @@ -575,6 +607,114 @@ Did you try connecting directly to this Durable Object? Try using getServerByNam return []; } + /** + * Execute a long-running async function while keeping the Durable Object alive. + * + * Durable Objects normally terminate 70-140s after the last network request. + * This method keeps the DO alive by establishing a WebSocket connection to itself + * and sending periodic ping messages. + * + * @experimental This API is experimental and may change in future versions. + * + * @param fn - The async function to execute + * @param timeoutMs - Maximum time to keep the DO alive (default: 30 minutes) + * @returns The result of the async function + * + * @remarks + * Requires the `enable_ctx_exports` compatibility flag in wrangler.jsonc: + * ```json + * { + * "compatibility_flags": ["enable_ctx_exports"] + * } + * ``` + * + * @example + * ```typescript + * const result = await this.experimental_waitUntil(async () => { + * // Long-running operation + * await processLargeDataset(); + * return { success: true }; + * }, 60 * 60 * 1000); // 1 hour timeout + * ``` + */ + async experimental_waitUntil( + fn: () => Promise, + timeoutMs: number = 30 * 60 * 1000 // 30 minutes default + ): Promise { + // Get namespace from ctx.exports (requires enable_ctx_exports compatibility flag) + const exports = ( + this.ctx as DurableObjectState & { exports?: Record } + ).exports; + if (!exports) { + throw new Error( + "waitUntil requires the 'enable_ctx_exports' compatibility flag. " + + 'Add it to your wrangler.jsonc: { "compatibility_flags": ["enable_ctx_exports"] }' + ); + } + + const namespace = exports[this.#ParentClass.name] as + | DurableObjectNamespace + | undefined; + if (!namespace) { + throw new Error( + `Could not find namespace for ${this.#ParentClass.name} in ctx.exports. ` + + "Make sure the class name matches your Durable Object binding." + ); + } + + const stub = namespace.get(this.ctx.id); + + // Connect to self via WebSocket for keep-alive + const response = await stub.fetch( + "http://dummy-example.cloudflare.com/cdn-cgi/partyserver/keep-alive/", + { + headers: { + Upgrade: "websocket", + "x-partykit-room": this.name + } + } + ); + + const ws = response.webSocket; + if (!ws) { + throw new Error("Failed to establish keep-alive WebSocket connection"); + } + ws.accept(); + + // Set up ping interval (every 10 seconds) + const pingInterval = setInterval(() => { + try { + ws.send("ping"); + } catch { + // WebSocket may have closed, ignore + } + }, 10_000); + + // Create a timeout promise that rejects after timeoutMs + let timeoutId: ReturnType; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject( + new Error(`experimental_waitUntil timed out after ${timeoutMs}ms`) + ); + }, timeoutMs); + }); + + try { + // Race the function against the timeout + const result = await Promise.race([fn(), timeoutPromise]); + return result; + } finally { + clearTimeout(timeoutId!); + clearInterval(pingInterval); + try { + ws.close(1000, "Complete"); + } catch { + // Ignore close errors + } + } + } + #_props?: Props; // Implemented by the user