Skip to content

add(feature): Make request.signal.onabort work in route handlers #847

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/brave-pants-nail.md
Original file line number Diff line number Diff line change
@@ -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/
50 changes: 50 additions & 0 deletions examples/playground15/app/api/request/signal/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"use client";

import { useState, useRef, useEffect } from "react";

export default function TestSignalPage() {
const eventSource = useRef<EventSource | null>(null);
const [messages, setMessages] = useState<string[]>([]);

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 (
<div>
<button data-testid="start-stream" onClick={startStream}>
Start Stream
</button>
<button data-testid="abort-stream" onClick={abortStream}>
Abort Stream
</button>
<div data-testid="messages">
{messages.map((msg, i) => (
<div key={i}>{msg}</div>
))}
</div>
</div>
);
}
35 changes: 35 additions & 0 deletions examples/playground15/app/api/request/signal/sse/route.ts
Original file line number Diff line number Diff line change
@@ -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",
},
});
}
2 changes: 1 addition & 1 deletion examples/playground15/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/cloudflare/src/cli/build/bundle-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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[],
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
});
}
"
`);
});
});
});
Loading