From 57a2f6a60d8e6e37d546ca2f6161ec79b7b6c436 Mon Sep 17 00:00:00 2001 From: Magnus Dahl Eide Date: Thu, 7 Aug 2025 01:59:00 +0200 Subject: [PATCH 1/3] add(feature): Make request.signal work in route handlers --- .../app/api/request/signal/page.tsx | 50 ++++ .../app/api/request/signal/sse/route.ts | 35 +++ examples/playground15/wrangler.jsonc | 2 +- .../cloudflare/src/cli/build/bundle-server.ts | 2 + .../plugins/patch-from-node-request.spec.ts | 270 ++++++++++++++++++ .../plugins/patch-from-node-request.ts | 135 +++++++++ packages/cloudflare/src/cli/templates/init.ts | 2 +- 7 files changed, 494 insertions(+), 2 deletions(-) create mode 100644 examples/playground15/app/api/request/signal/page.tsx create mode 100644 examples/playground15/app/api/request/signal/sse/route.ts create mode 100644 packages/cloudflare/src/cli/build/patches/plugins/patch-from-node-request.spec.ts create mode 100644 packages/cloudflare/src/cli/build/patches/plugins/patch-from-node-request.ts 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; From 1f098db00b524554bc3b02c119ea92a0179a7a33 Mon Sep 17 00:00:00 2001 From: Magnus Dahl Eide Date: Thu, 7 Aug 2025 02:11:01 +0200 Subject: [PATCH 2/3] changeset --- .changeset/brave-pants-nail.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/brave-pants-nail.md diff --git a/.changeset/brave-pants-nail.md b/.changeset/brave-pants-nail.md new file mode 100644 index 00000000..8e3181ed --- /dev/null +++ b/.changeset/brave-pants-nail.md @@ -0,0 +1,9 @@ +--- +"@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/ \ No newline at end of file From 8b953f3e76d9e38c24d20b9574de8209b38e710c Mon Sep 17 00:00:00 2001 From: Magnus Dahl Eide Date: Thu, 7 Aug 2025 02:14:45 +0200 Subject: [PATCH 3/3] pretty --- .changeset/brave-pants-nail.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/brave-pants-nail.md b/.changeset/brave-pants-nail.md index 8e3181ed..2540a99e 100644 --- a/.changeset/brave-pants-nail.md +++ b/.changeset/brave-pants-nail.md @@ -3,7 +3,8 @@ --- 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/ \ No newline at end of file +https://developers.cloudflare.com/changelog/2025-05-22-handle-request-cancellation/