diff --git a/.changeset/brave-pants-nail.md b/.changeset/brave-pants-nail.md new file mode 100644 index 00000000..2540a99e --- /dev/null +++ b/.changeset/brave-pants-nail.md @@ -0,0 +1,10 @@ +--- +"@opennextjs/cloudflare": patch +--- + +add(feature): Make request.signal.onabort work in route handlers + +Patch fromNodeNextRequest to pass in the original request signal onto NextRequest you recieve in route handlers. + +Cloudflare Workers do now support this API on the request object you recieve in fetch. Read more about the release here: +https://developers.cloudflare.com/changelog/2025-05-22-handle-request-cancellation/ diff --git a/examples/playground15/app/api/request/signal/page.tsx b/examples/playground15/app/api/request/signal/page.tsx new file mode 100644 index 00000000..3fe05a60 --- /dev/null +++ b/examples/playground15/app/api/request/signal/page.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; + +export default function TestSignalPage() { + const eventSource = useRef(null); + const [messages, setMessages] = useState([]); + + function startStream() { + if (eventSource.current) { + eventSource.current.close(); + } + eventSource.current = new EventSource("/api/request/signal/sse"); + eventSource.current.onmessage = (event) => { + setMessages((prev) => [...prev, event.data]); + }; + eventSource.current.onerror = () => { + abortStream(); + }; + } + + function abortStream() { + if (eventSource.current) { + eventSource.current.close(); + eventSource.current = null; + } + } + + useEffect(() => { + return () => { + abortStream(); + }; + }, []); + + return ( +
+ + +
+ {messages.map((msg, i) => ( +
{msg}
+ ))} +
+
+ ); +} diff --git a/examples/playground15/app/api/request/signal/sse/route.ts b/examples/playground15/app/api/request/signal/sse/route.ts new file mode 100644 index 00000000..9cb24936 --- /dev/null +++ b/examples/playground15/app/api/request/signal/sse/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; + +function sleep(time: number) { + return new Promise(async (resolve) => { + setTimeout(resolve, time); + }); +} + +export async function GET(request: NextRequest) { + console.log(globalThis[Symbol.for("__cloudflare-context__")].abortSignal === request.signal); + const stream = new ReadableStream({ + async start(controller) { + request.signal.addEventListener("abort", () => { + console.log("====== abort ======"); + controller.close(); + }); + + while (!request.signal.aborted) { + console.log("===== enqueue ====="); + controller.enqueue( + new TextEncoder().encode(`data: ${JSON.stringify({ number: Math.random() })}\n\n`) + ); + await sleep(2 * 1000); + } + }, + }); + + return new NextResponse(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} diff --git a/examples/playground15/wrangler.jsonc b/examples/playground15/wrangler.jsonc index 8d4791c4..2d62e087 100644 --- a/examples/playground15/wrangler.jsonc +++ b/examples/playground15/wrangler.jsonc @@ -3,7 +3,7 @@ "main": ".open-next/worker.js", "name": "playground15", "compatibility_date": "2024-12-30", - "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], + "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public", "enable_request_signal"], "assets": { "directory": ".open-next/assets", "binding": "ASSETS", diff --git a/packages/cloudflare/src/cli/build/bundle-server.ts b/packages/cloudflare/src/cli/build/bundle-server.ts index 3fabca37..cb156fea 100644 --- a/packages/cloudflare/src/cli/build/bundle-server.ts +++ b/packages/cloudflare/src/cli/build/bundle-server.ts @@ -21,6 +21,7 @@ import { patchResolveCache } from "./patches/plugins/open-next.js"; import { handleOptionalDependencies } from "./patches/plugins/optional-deps.js"; import { patchPagesRouterContext } from "./patches/plugins/pages-router-context.js"; import { patchDepdDeprecations } from "./patches/plugins/patch-depd-deprecations.js"; +import { patchFromNodeRequest } from "./patches/plugins/patch-from-node-request.js"; import { fixRequire } from "./patches/plugins/require.js"; import { shimRequireHook } from "./patches/plugins/require-hook.js"; import { patchRouteModules } from "./patches/plugins/route-module.js"; @@ -109,6 +110,7 @@ export async function bundleServer(buildOpts: BuildOptions, projectOpts: Project patchDepdDeprecations(updater), patchResolveCache(updater, buildOpts), patchNodeEnvironment(updater), + patchFromNodeRequest(updater), // Apply updater updates, must be the last plugin updater.plugin, ] as Plugin[], diff --git a/packages/cloudflare/src/cli/build/patches/plugins/patch-from-node-request.spec.ts b/packages/cloudflare/src/cli/build/patches/plugins/patch-from-node-request.spec.ts new file mode 100644 index 00000000..0a7ef775 --- /dev/null +++ b/packages/cloudflare/src/cli/build/patches/plugins/patch-from-node-request.spec.ts @@ -0,0 +1,270 @@ +import { describe, expect, test } from "vitest"; + +import { computePatchDiff } from "../../utils/test-patch.js"; +import { + signalIdentifierRuleBundled, + signalIdentifierRuleUnbundled, + signalSpreadElement, +} from "./patch-from-node-request.js"; + +describe("fromNodeRequest", () => { + const codeUnbundled = ` +"use strict"; +Object.defineProperty(exports, "__esModule", { + value: true +}); +0 && (module.exports = { + NextRequestAdapter: null, + ResponseAborted: null, + ResponseAbortedName: null, + createAbortController: null, + signalFromNodeResponse: null +}); +function _export(target, all) { + for(var name in all)Object.defineProperty(target, name, { + enumerable: true, + get: all[name] + }); +} +_export(exports, { + NextRequestAdapter: function() { + return NextRequestAdapter; + }, + ResponseAborted: function() { + return ResponseAborted; + }, + ResponseAbortedName: function() { + return ResponseAbortedName; + }, + createAbortController: function() { + return createAbortController; + }, + signalFromNodeResponse: function() { + return signalFromNodeResponse; + } +}); +const _requestmeta = require("../../../request-meta"); +const _utils = require("../../utils"); +const _request = require("../request"); +const _helpers = require("../../../base-http/helpers"); +const ResponseAbortedName = 'ResponseAborted'; +class ResponseAborted extends Error { + constructor(...args){ + super(...args), this.name = ResponseAbortedName; + } +} +function createAbortController(response) { + const controller = new AbortController(); + response.once('close', ()=>{ + if (response.writableFinished) return; + controller.abort(new ResponseAborted()); + }); + return controller; +} +function signalFromNodeResponse(response) { + const { errored, destroyed } = response; + if (errored || destroyed) { + return AbortSignal.abort(errored ?? new ResponseAborted()); + } + const { signal } = createAbortController(response); + return signal; +} +class NextRequestAdapter { + static fromBaseNextRequest(request, signal) { + if (// The type check here ensures that \`req\` is correctly typed, and the + // environment variable check provides dead code elimination. + process.env.NEXT_RUNTIME === 'edge' && (0, _helpers.isWebNextRequest)(request)) { + return NextRequestAdapter.fromWebNextRequest(request); + } else if (// The type check here ensures that \`req\` is correctly typed, and the + // environment variable check provides dead code elimination. + process.env.NEXT_RUNTIME !== 'edge' && (0, _helpers.isNodeNextRequest)(request)) { + return NextRequestAdapter.fromNodeNextRequest(request, signal); + } else { + throw Object.defineProperty(new Error('Invariant: Unsupported NextRequest type'), "__NEXT_ERROR_CODE", { + value: "E345", + enumerable: false, + configurable: true + }); + } + } + static fromNodeNextRequest(request, signal) { + // HEAD and GET requests can not have a body. + let body = null; + if (request.method !== 'GET' && request.method !== 'HEAD' && request.body) { + body = request.body; + } + let url; + if (request.url.startsWith('http')) { + url = new URL(request.url); + } else { + // Grab the full URL from the request metadata. + const base = (0, _requestmeta.getRequestMeta)(request, 'initURL'); + if (!base || !base.startsWith('http')) { + // Because the URL construction relies on the fact that the URL provided + // is absolute, we need to provide a base URL. We can't use the request + // URL because it's relative, so we use a dummy URL instead. + url = new URL(request.url, 'http://n'); + } else { + url = new URL(request.url, base); + } + } + return new _request.NextRequest(url, { + method: request.method, + headers: (0, _utils.fromNodeOutgoingHttpHeaders)(request.headers), + duplex: 'half', + signal, + // geo + // ip + // nextConfig + // body can not be passed if request was aborted + // or we get a Request body was disturbed error + ...request.request.signal.aborted ? {} : { + body + } + }); + } + static fromWebNextRequest(request) { + // HEAD and GET requests can not have a body. + let body = null; + if (request.method !== 'GET' && request.method !== 'HEAD') { + body = request.body; + } + return new _request.NextRequest(request.url, { + method: request.method, + headers: (0, _utils.fromNodeOutgoingHttpHeaders)(request.headers), + duplex: 'half', + signal: request.request.signal, + // geo + // ip + // nextConfig + // body can not be passed if request was aborted + // or we get a Request body was disturbed error + ...request.request.signal.aborted ? {} : { + body + } + }); + } +} + `; + + const codeBundled = `class d { + static fromBaseNextRequest(e2, t2) { + if ((0, i.isNodeNextRequest)(e2)) return d.fromNodeNextRequest(e2, t2); + throw Object.defineProperty(Error("Invariant: Unsupported NextRequest type"), "__NEXT_ERROR_CODE", { value: "E345", enumerable: false, configurable: true }); + } + static fromNodeNextRequest(e2, t2) { + let r2, i2 = null; + if ("GET" !== e2.method && "HEAD" !== e2.method && e2.body && (i2 = e2.body), e2.url.startsWith("http")) r2 = new URL(e2.url); + else { + let t3 = (0, n.getRequestMeta)(e2, "initURL"); + r2 = t3 && t3.startsWith("http") ? new URL(e2.url, t3) : new URL(e2.url, "http://n"); + } + return new o.NextRequest(r2, { method: e2.method, headers: (0, a.fromNodeOutgoingHttpHeaders)(e2.headers), duplex: "half", signal: t2, ...t2.aborted ? {} : { body: i2 } }); + } + static fromWebNextRequest(e2) { + let t2 = null; + return "GET" !== e2.method && "HEAD" !== e2.method && (t2 = e2.body), new o.NextRequest(e2.url, { method: e2.method, headers: (0, a.fromNodeOutgoingHttpHeaders)(e2.headers), duplex: "half", signal: e2.request.signal, ...e2.request.signal.aborted ? {} : { body: t2 } }); + } +}`; + describe("should patch bundled code", () => { + test("signal shorthand property identifier", () => { + expect(computePatchDiff("next-request.js", codeBundled, signalIdentifierRuleBundled)) + .toMatchInlineSnapshot(` + "Index: next-request.js + =================================================================== + --- next-request.js + +++ next-request.js + @@ -9,9 +9,9 @@ + else { + let t3 = (0, n.getRequestMeta)(e2, "initURL"); + r2 = t3 && t3.startsWith("http") ? new URL(e2.url, t3) : new URL(e2.url, "http://n"); + } + - return new o.NextRequest(r2, { method: e2.method, headers: (0, a.fromNodeOutgoingHttpHeaders)(e2.headers), duplex: "half", signal: t2, ...t2.aborted ? {} : { body: i2 } }); + + return new o.NextRequest(r2, { method: e2.method, headers: (0, a.fromNodeOutgoingHttpHeaders)(e2.headers), duplex: "half", signal: globalThis[Symbol.for("__cloudflare-context__")].abortSignal, ...t2.aborted ? {} : { body: i2 } }); + } + static fromWebNextRequest(e2) { + let t2 = null; + return "GET" !== e2.method && "HEAD" !== e2.method && (t2 = e2.body), new o.NextRequest(e2.url, { method: e2.method, headers: (0, a.fromNodeOutgoingHttpHeaders)(e2.headers), duplex: "half", signal: e2.request.signal, ...e2.request.signal.aborted ? {} : { body: t2 } }); + " + `); + }); + + test("signal spread element", () => { + expect(computePatchDiff("next-request.js", codeBundled, signalSpreadElement)).toMatchInlineSnapshot(` + "Index: next-request.js + =================================================================== + --- next-request.js + +++ next-request.js + @@ -9,9 +9,9 @@ + else { + let t3 = (0, n.getRequestMeta)(e2, "initURL"); + r2 = t3 && t3.startsWith("http") ? new URL(e2.url, t3) : new URL(e2.url, "http://n"); + } + - return new o.NextRequest(r2, { method: e2.method, headers: (0, a.fromNodeOutgoingHttpHeaders)(e2.headers), duplex: "half", signal: t2, ...t2.aborted ? {} : { body: i2 } }); + + return new o.NextRequest(r2, { method: e2.method, headers: (0, a.fromNodeOutgoingHttpHeaders)(e2.headers), duplex: "half", signal: t2, ...globalThis[Symbol.for("__cloudflare-context__")].abortSignal.aborted ? {} : { body: i2 } }); + } + static fromWebNextRequest(e2) { + let t2 = null; + return "GET" !== e2.method && "HEAD" !== e2.method && (t2 = e2.body), new o.NextRequest(e2.url, { method: e2.method, headers: (0, a.fromNodeOutgoingHttpHeaders)(e2.headers), duplex: "half", signal: e2.request.signal, ...e2.request.signal.aborted ? {} : { body: t2 } }); + " + `); + }); + }); + + describe("should patch unbundled code", () => { + test("signal shorthand property identifier", () => { + expect(computePatchDiff("next-request.js", codeUnbundled, signalIdentifierRuleUnbundled)) + .toMatchInlineSnapshot(` + "Index: next-request.js + =================================================================== + --- next-request.js + +++ next-request.js + @@ -1,5 +1,4 @@ + - + "use strict"; + Object.defineProperty(exports, "__esModule", { + value: true + }); + @@ -101,9 +100,9 @@ + return new _request.NextRequest(url, { + method: request.method, + headers: (0, _utils.fromNodeOutgoingHttpHeaders)(request.headers), + duplex: 'half', + - signal, + + signal: globalThis[Symbol.for("__cloudflare-context__")].abortSignal, + // geo + // ip + // nextConfig + // body can not be passed if request was aborted + " + `); + }); + + test("signal spread element", () => { + expect(computePatchDiff("next-request.js", codeUnbundled, signalSpreadElement)).toMatchInlineSnapshot(` + "Index: next-request.js + =================================================================== + --- next-request.js + +++ next-request.js + @@ -1,5 +1,4 @@ + - + "use strict"; + Object.defineProperty(exports, "__esModule", { + value: true + }); + @@ -107,9 +106,9 @@ + // ip + // nextConfig + // body can not be passed if request was aborted + // or we get a Request body was disturbed error + - ...request.request.signal.aborted ? {} : { + + ...globalThis[Symbol.for("__cloudflare-context__")].abortSignal.aborted ? {} : { + body + } + }); + } + " + `); + }); + }); +}); diff --git a/packages/cloudflare/src/cli/build/patches/plugins/patch-from-node-request.ts b/packages/cloudflare/src/cli/build/patches/plugins/patch-from-node-request.ts new file mode 100644 index 00000000..a0bc9c4a --- /dev/null +++ b/packages/cloudflare/src/cli/build/patches/plugins/patch-from-node-request.ts @@ -0,0 +1,135 @@ +/** + * Patch for `next/dist/server/web/spec-extension/adapters/next-request.js` + * https://github.com/vercel/next.js/blob/ea08bf27/packages/next/src/server/web/spec-extension/adapters/next-request.ts#L107-L125 + * + * Patch fromNodeNextRequest to pass in the original request signal onto NextRequest + * + * Cloudflare Workers do now support this API. Read more about the release here: + * https://developers.cloudflare.com/changelog/2025-05-22-handle-request-cancellation/ + * + * TODO: test on latest Next 14 + * + */ + +import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js"; +import type { ContentUpdater, Plugin } from "@opennextjs/aws/plugins/content-updater.js"; +import { getCrossPlatformPathRegex } from "@opennextjs/aws/utils/regex.js"; + +export function patchFromNodeRequest(updater: ContentUpdater): Plugin { + return updater.updateContent("from-node-request", [ + { + filter: getCrossPlatformPathRegex( + String.raw`next/dist/server/web/spec-extension/adapters/next-request.js`, + { + escape: false, + } + ), + versions: ">=15.0.0", + contentFilter: /fromNodeNextRequest\(/, + callback: ({ contents, path }) => { + console.log(path); + contents = patchCode(contents, signalIdentifierRuleUnbundled); + contents = patchCode(contents, signalSpreadElement); + return contents; + }, + }, + { + filter: getCrossPlatformPathRegex(String.raw`\.next/server/.*\.js$`, { + escape: false, + }), + versions: ">=15.0.0", + contentFilter: /fromNodeNextRequest\(/, + callback: ({ contents, path }) => { + console.log(path); + contents = patchCode(contents, signalIdentifierRuleBundled); + contents = patchCode(contents, signalSpreadElement); + return contents; + }, + }, + ]); +} +/** + * This didn't work for some reason + */ +export const signalIdentifierRuleUnbundled = ` +rule: + kind: shorthand_property_identifier + regex: ^signal$ + inside: + kind: object + inside: + kind: arguments + has: + regex: fromNodeOutgoingHttpHeaders + inside: + kind: new_expression + has: + regex: NextRequest + inside: + kind: return_statement + inside: + kind: statement_block + inside: + kind: method_definition + inside: + kind: class_body + inside: + kind: class_declaration +fix: + 'signal: globalThis[Symbol.for("__cloudflare-context__")].abortSignal' +`; + +export const signalSpreadElement = ` +rule: + pattern: + selector: member_expression + context: "$A.aborted" + inside: + kind: ternary_expression + inside: + kind: spread_element + inside: + kind: object + inside: + kind: arguments + inside: + kind: new_expression + has: + kind: member_expression + has: + kind: property_identifier + regex: NextRequest + inside: + kind: return_statement + inside: + kind: statement_block + inside: + kind: method_definition + has: + field: name + regex: ^fromNodeNextRequest$ +fix: + globalThis[Symbol.for("__cloudflare-context__")].abortSignal.aborted +`; + +export const signalIdentifierRuleBundled = ` +rule: + pattern: + selector: identifier + context: "signal: $A" + inside: + kind: pair + inside: + kind: object + inside: + kind: arguments + inside: + kind: new_expression + has: + kind: member_expression + has: + kind: property_identifier + regex: NextRequest +fix: + globalThis[Symbol.for("__cloudflare-context__")].abortSignal +`; diff --git a/packages/cloudflare/src/cli/templates/init.ts b/packages/cloudflare/src/cli/templates/init.ts index 49ac9fff..63b36ee6 100644 --- a/packages/cloudflare/src/cli/templates/init.ts +++ b/packages/cloudflare/src/cli/templates/init.ts @@ -31,7 +31,7 @@ export async function runWithCloudflareRequestContext( ): Promise { init(request, env); - return cloudflareContextALS.run({ env, ctx, cf: request.cf }, handler); + return cloudflareContextALS.run({ env, ctx, cf: request.cf, abortSignal: request.signal }, handler); } let initialized = false;