Skip to content

Commit 57a2f6a

Browse files
committed
add(feature): Make request.signal work in route handlers
1 parent 060a03e commit 57a2f6a

File tree

7 files changed

+494
-2
lines changed

7 files changed

+494
-2
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"use client";
2+
3+
import { useState, useRef, useEffect } from "react";
4+
5+
export default function TestSignalPage() {
6+
const eventSource = useRef<EventSource | null>(null);
7+
const [messages, setMessages] = useState<string[]>([]);
8+
9+
function startStream() {
10+
if (eventSource.current) {
11+
eventSource.current.close();
12+
}
13+
eventSource.current = new EventSource("/api/request/signal/sse");
14+
eventSource.current.onmessage = (event) => {
15+
setMessages((prev) => [...prev, event.data]);
16+
};
17+
eventSource.current.onerror = () => {
18+
abortStream();
19+
};
20+
}
21+
22+
function abortStream() {
23+
if (eventSource.current) {
24+
eventSource.current.close();
25+
eventSource.current = null;
26+
}
27+
}
28+
29+
useEffect(() => {
30+
return () => {
31+
abortStream();
32+
};
33+
}, []);
34+
35+
return (
36+
<div>
37+
<button data-testid="start-stream" onClick={startStream}>
38+
Start Stream
39+
</button>
40+
<button data-testid="abort-stream" onClick={abortStream}>
41+
Abort Stream
42+
</button>
43+
<div data-testid="messages">
44+
{messages.map((msg, i) => (
45+
<div key={i}>{msg}</div>
46+
))}
47+
</div>
48+
</div>
49+
);
50+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
3+
function sleep(time: number) {
4+
return new Promise(async (resolve) => {
5+
setTimeout(resolve, time);
6+
});
7+
}
8+
9+
export async function GET(request: NextRequest) {
10+
console.log(globalThis[Symbol.for("__cloudflare-context__")].abortSignal === request.signal);
11+
const stream = new ReadableStream({
12+
async start(controller) {
13+
request.signal.addEventListener("abort", () => {
14+
console.log("====== abort ======");
15+
controller.close();
16+
});
17+
18+
while (!request.signal.aborted) {
19+
console.log("===== enqueue =====");
20+
controller.enqueue(
21+
new TextEncoder().encode(`data: ${JSON.stringify({ number: Math.random() })}\n\n`)
22+
);
23+
await sleep(2 * 1000);
24+
}
25+
},
26+
});
27+
28+
return new NextResponse(stream, {
29+
headers: {
30+
"Content-Type": "text/event-stream",
31+
"Cache-Control": "no-cache",
32+
Connection: "keep-alive",
33+
},
34+
});
35+
}

examples/playground15/wrangler.jsonc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"main": ".open-next/worker.js",
44
"name": "playground15",
55
"compatibility_date": "2024-12-30",
6-
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
6+
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public", "enable_request_signal"],
77
"assets": {
88
"directory": ".open-next/assets",
99
"binding": "ASSETS",

packages/cloudflare/src/cli/build/bundle-server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { patchResolveCache } from "./patches/plugins/open-next.js";
2121
import { handleOptionalDependencies } from "./patches/plugins/optional-deps.js";
2222
import { patchPagesRouterContext } from "./patches/plugins/pages-router-context.js";
2323
import { patchDepdDeprecations } from "./patches/plugins/patch-depd-deprecations.js";
24+
import { patchFromNodeRequest } from "./patches/plugins/patch-from-node-request.js";
2425
import { fixRequire } from "./patches/plugins/require.js";
2526
import { shimRequireHook } from "./patches/plugins/require-hook.js";
2627
import { patchRouteModules } from "./patches/plugins/route-module.js";
@@ -109,6 +110,7 @@ export async function bundleServer(buildOpts: BuildOptions, projectOpts: Project
109110
patchDepdDeprecations(updater),
110111
patchResolveCache(updater, buildOpts),
111112
patchNodeEnvironment(updater),
113+
patchFromNodeRequest(updater),
112114
// Apply updater updates, must be the last plugin
113115
updater.plugin,
114116
] as Plugin[],
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import { describe, expect, test } from "vitest";
2+
3+
import { computePatchDiff } from "../../utils/test-patch.js";
4+
import {
5+
signalIdentifierRuleBundled,
6+
signalIdentifierRuleUnbundled,
7+
signalSpreadElement,
8+
} from "./patch-from-node-request.js";
9+
10+
describe("fromNodeRequest", () => {
11+
const codeUnbundled = `
12+
"use strict";
13+
Object.defineProperty(exports, "__esModule", {
14+
value: true
15+
});
16+
0 && (module.exports = {
17+
NextRequestAdapter: null,
18+
ResponseAborted: null,
19+
ResponseAbortedName: null,
20+
createAbortController: null,
21+
signalFromNodeResponse: null
22+
});
23+
function _export(target, all) {
24+
for(var name in all)Object.defineProperty(target, name, {
25+
enumerable: true,
26+
get: all[name]
27+
});
28+
}
29+
_export(exports, {
30+
NextRequestAdapter: function() {
31+
return NextRequestAdapter;
32+
},
33+
ResponseAborted: function() {
34+
return ResponseAborted;
35+
},
36+
ResponseAbortedName: function() {
37+
return ResponseAbortedName;
38+
},
39+
createAbortController: function() {
40+
return createAbortController;
41+
},
42+
signalFromNodeResponse: function() {
43+
return signalFromNodeResponse;
44+
}
45+
});
46+
const _requestmeta = require("../../../request-meta");
47+
const _utils = require("../../utils");
48+
const _request = require("../request");
49+
const _helpers = require("../../../base-http/helpers");
50+
const ResponseAbortedName = 'ResponseAborted';
51+
class ResponseAborted extends Error {
52+
constructor(...args){
53+
super(...args), this.name = ResponseAbortedName;
54+
}
55+
}
56+
function createAbortController(response) {
57+
const controller = new AbortController();
58+
response.once('close', ()=>{
59+
if (response.writableFinished) return;
60+
controller.abort(new ResponseAborted());
61+
});
62+
return controller;
63+
}
64+
function signalFromNodeResponse(response) {
65+
const { errored, destroyed } = response;
66+
if (errored || destroyed) {
67+
return AbortSignal.abort(errored ?? new ResponseAborted());
68+
}
69+
const { signal } = createAbortController(response);
70+
return signal;
71+
}
72+
class NextRequestAdapter {
73+
static fromBaseNextRequest(request, signal) {
74+
if (// The type check here ensures that \`req\` is correctly typed, and the
75+
// environment variable check provides dead code elimination.
76+
process.env.NEXT_RUNTIME === 'edge' && (0, _helpers.isWebNextRequest)(request)) {
77+
return NextRequestAdapter.fromWebNextRequest(request);
78+
} else if (// The type check here ensures that \`req\` is correctly typed, and the
79+
// environment variable check provides dead code elimination.
80+
process.env.NEXT_RUNTIME !== 'edge' && (0, _helpers.isNodeNextRequest)(request)) {
81+
return NextRequestAdapter.fromNodeNextRequest(request, signal);
82+
} else {
83+
throw Object.defineProperty(new Error('Invariant: Unsupported NextRequest type'), "__NEXT_ERROR_CODE", {
84+
value: "E345",
85+
enumerable: false,
86+
configurable: true
87+
});
88+
}
89+
}
90+
static fromNodeNextRequest(request, signal) {
91+
// HEAD and GET requests can not have a body.
92+
let body = null;
93+
if (request.method !== 'GET' && request.method !== 'HEAD' && request.body) {
94+
body = request.body;
95+
}
96+
let url;
97+
if (request.url.startsWith('http')) {
98+
url = new URL(request.url);
99+
} else {
100+
// Grab the full URL from the request metadata.
101+
const base = (0, _requestmeta.getRequestMeta)(request, 'initURL');
102+
if (!base || !base.startsWith('http')) {
103+
// Because the URL construction relies on the fact that the URL provided
104+
// is absolute, we need to provide a base URL. We can't use the request
105+
// URL because it's relative, so we use a dummy URL instead.
106+
url = new URL(request.url, 'http://n');
107+
} else {
108+
url = new URL(request.url, base);
109+
}
110+
}
111+
return new _request.NextRequest(url, {
112+
method: request.method,
113+
headers: (0, _utils.fromNodeOutgoingHttpHeaders)(request.headers),
114+
duplex: 'half',
115+
signal,
116+
// geo
117+
// ip
118+
// nextConfig
119+
// body can not be passed if request was aborted
120+
// or we get a Request body was disturbed error
121+
...request.request.signal.aborted ? {} : {
122+
body
123+
}
124+
});
125+
}
126+
static fromWebNextRequest(request) {
127+
// HEAD and GET requests can not have a body.
128+
let body = null;
129+
if (request.method !== 'GET' && request.method !== 'HEAD') {
130+
body = request.body;
131+
}
132+
return new _request.NextRequest(request.url, {
133+
method: request.method,
134+
headers: (0, _utils.fromNodeOutgoingHttpHeaders)(request.headers),
135+
duplex: 'half',
136+
signal: request.request.signal,
137+
// geo
138+
// ip
139+
// nextConfig
140+
// body can not be passed if request was aborted
141+
// or we get a Request body was disturbed error
142+
...request.request.signal.aborted ? {} : {
143+
body
144+
}
145+
});
146+
}
147+
}
148+
`;
149+
150+
const codeBundled = `class d {
151+
static fromBaseNextRequest(e2, t2) {
152+
if ((0, i.isNodeNextRequest)(e2)) return d.fromNodeNextRequest(e2, t2);
153+
throw Object.defineProperty(Error("Invariant: Unsupported NextRequest type"), "__NEXT_ERROR_CODE", { value: "E345", enumerable: false, configurable: true });
154+
}
155+
static fromNodeNextRequest(e2, t2) {
156+
let r2, i2 = null;
157+
if ("GET" !== e2.method && "HEAD" !== e2.method && e2.body && (i2 = e2.body), e2.url.startsWith("http")) r2 = new URL(e2.url);
158+
else {
159+
let t3 = (0, n.getRequestMeta)(e2, "initURL");
160+
r2 = t3 && t3.startsWith("http") ? new URL(e2.url, t3) : new URL(e2.url, "http://n");
161+
}
162+
return new o.NextRequest(r2, { method: e2.method, headers: (0, a.fromNodeOutgoingHttpHeaders)(e2.headers), duplex: "half", signal: t2, ...t2.aborted ? {} : { body: i2 } });
163+
}
164+
static fromWebNextRequest(e2) {
165+
let t2 = null;
166+
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 } });
167+
}
168+
}`;
169+
describe("should patch bundled code", () => {
170+
test("signal shorthand property identifier", () => {
171+
expect(computePatchDiff("next-request.js", codeBundled, signalIdentifierRuleBundled))
172+
.toMatchInlineSnapshot(`
173+
"Index: next-request.js
174+
===================================================================
175+
--- next-request.js
176+
+++ next-request.js
177+
@@ -9,9 +9,9 @@
178+
else {
179+
let t3 = (0, n.getRequestMeta)(e2, "initURL");
180+
r2 = t3 && t3.startsWith("http") ? new URL(e2.url, t3) : new URL(e2.url, "http://n");
181+
}
182+
- return new o.NextRequest(r2, { method: e2.method, headers: (0, a.fromNodeOutgoingHttpHeaders)(e2.headers), duplex: "half", signal: t2, ...t2.aborted ? {} : { body: i2 } });
183+
+ 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 } });
184+
}
185+
static fromWebNextRequest(e2) {
186+
let t2 = null;
187+
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 } });
188+
"
189+
`);
190+
});
191+
192+
test("signal spread element", () => {
193+
expect(computePatchDiff("next-request.js", codeBundled, signalSpreadElement)).toMatchInlineSnapshot(`
194+
"Index: next-request.js
195+
===================================================================
196+
--- next-request.js
197+
+++ next-request.js
198+
@@ -9,9 +9,9 @@
199+
else {
200+
let t3 = (0, n.getRequestMeta)(e2, "initURL");
201+
r2 = t3 && t3.startsWith("http") ? new URL(e2.url, t3) : new URL(e2.url, "http://n");
202+
}
203+
- return new o.NextRequest(r2, { method: e2.method, headers: (0, a.fromNodeOutgoingHttpHeaders)(e2.headers), duplex: "half", signal: t2, ...t2.aborted ? {} : { body: i2 } });
204+
+ 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 } });
205+
}
206+
static fromWebNextRequest(e2) {
207+
let t2 = null;
208+
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 } });
209+
"
210+
`);
211+
});
212+
});
213+
214+
describe("should patch unbundled code", () => {
215+
test("signal shorthand property identifier", () => {
216+
expect(computePatchDiff("next-request.js", codeUnbundled, signalIdentifierRuleUnbundled))
217+
.toMatchInlineSnapshot(`
218+
"Index: next-request.js
219+
===================================================================
220+
--- next-request.js
221+
+++ next-request.js
222+
@@ -1,5 +1,4 @@
223+
-
224+
"use strict";
225+
Object.defineProperty(exports, "__esModule", {
226+
value: true
227+
});
228+
@@ -101,9 +100,9 @@
229+
return new _request.NextRequest(url, {
230+
method: request.method,
231+
headers: (0, _utils.fromNodeOutgoingHttpHeaders)(request.headers),
232+
duplex: 'half',
233+
- signal,
234+
+ signal: globalThis[Symbol.for("__cloudflare-context__")].abortSignal,
235+
// geo
236+
// ip
237+
// nextConfig
238+
// body can not be passed if request was aborted
239+
"
240+
`);
241+
});
242+
243+
test("signal spread element", () => {
244+
expect(computePatchDiff("next-request.js", codeUnbundled, signalSpreadElement)).toMatchInlineSnapshot(`
245+
"Index: next-request.js
246+
===================================================================
247+
--- next-request.js
248+
+++ next-request.js
249+
@@ -1,5 +1,4 @@
250+
-
251+
"use strict";
252+
Object.defineProperty(exports, "__esModule", {
253+
value: true
254+
});
255+
@@ -107,9 +106,9 @@
256+
// ip
257+
// nextConfig
258+
// body can not be passed if request was aborted
259+
// or we get a Request body was disturbed error
260+
- ...request.request.signal.aborted ? {} : {
261+
+ ...globalThis[Symbol.for("__cloudflare-context__")].abortSignal.aborted ? {} : {
262+
body
263+
}
264+
});
265+
}
266+
"
267+
`);
268+
});
269+
});
270+
});

0 commit comments

Comments
 (0)