Skip to content

Commit f80c801

Browse files
authored
Ensure that the initial request.signal is passed to the wrapper (#848)
1 parent cc1ecbd commit f80c801

File tree

9 files changed

+226
-3
lines changed

9 files changed

+226
-3
lines changed

.changeset/four-walls-read.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@opennextjs/cloudflare": minor
3+
---
4+
5+
Ensure that the initial request.signal is passed to the wrapper
6+
7+
`request.signal.onabort` is now supported in route handlers. It requires that the signal from the original worker's request is passed to the handler. It will then pass along that `AbortSignal` through the `streamCreator` in the wrapper. This signal will destroy the response sent to NextServer when a client aborts, thus triggering the signal in the route handler.
8+
9+
See the changelog in Cloudflare [here](https://developers.cloudflare.com/changelog/2025-05-22-handle-request-cancellation/).
10+
11+
**Note:**
12+
If you have a custom worker, you must update your code to pass the original `request.signal` to the handler. You also need to enable the compatibility flag `enable_request_signal` to use this feature.
13+
14+
For example:
15+
16+
```js
17+
// Before:
18+
return handler(reqOrResp, env, ctx);
19+
20+
// After:
21+
return handler(reqOrResp, env, ctx, request.signal);
22+
```
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
3+
export async function GET(request: NextRequest) {
4+
const stream = new ReadableStream({
5+
async start(controller) {
6+
request.signal.addEventListener("abort", async () => {
7+
/**
8+
* I was not allowed to `revalidatePath` or `revalidateTag` here. I would run into this error from Next:
9+
* Error: Invariant: static generation store missing in revalidatePath
10+
*
11+
* Affected line:
12+
* https://github.com/vercel/next.js/blob/ea08bf27/packages/next/src/server/web/spec-extension/revalidate.ts#L89-L92
13+
*
14+
*/
15+
const host = new URL(request.url).host;
16+
// We need to set the protocol to http, cause in `wrangler dev` it will be https
17+
await fetch(`http://${host}/api/signal/revalidate`);
18+
19+
try {
20+
controller.close();
21+
} catch (_) {
22+
// Controller might already be closed, which is fine
23+
// This does only happen in `next start`
24+
}
25+
});
26+
27+
let i = 0;
28+
while (!request.signal.aborted) {
29+
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ number: i++ })}\n\n`));
30+
await new Promise((resolve) => setTimeout(resolve, 2_000));
31+
}
32+
},
33+
});
34+
35+
return new NextResponse(stream, {
36+
headers: {
37+
"Content-Type": "text/event-stream",
38+
"Cache-Control": "no-cache",
39+
Connection: "keep-alive",
40+
},
41+
});
42+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { revalidatePath } from "next/cache";
2+
3+
export const dynamic = "force-dynamic";
4+
5+
export async function GET() {
6+
revalidatePath("/signal");
7+
8+
return new Response("ok");
9+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"use client";
2+
3+
import { useEffect, useRef, useState } from "react";
4+
5+
export default function SSE() {
6+
const [events, setEvents] = useState<any[]>([]);
7+
const [start, setStart] = useState(false);
8+
const eventSourceRef = useRef<EventSource | null>(null);
9+
10+
useEffect(() => {
11+
if (start) {
12+
const e = new EventSource("/api/signal/abort");
13+
eventSourceRef.current = e;
14+
15+
e.onmessage = (msg) => {
16+
try {
17+
const data = JSON.parse(msg.data);
18+
setEvents((prev) => prev.concat(data));
19+
} catch (err) {
20+
console.log("failed to parse: ", err, msg);
21+
}
22+
};
23+
}
24+
25+
return () => {
26+
if (eventSourceRef.current) {
27+
eventSourceRef.current.close();
28+
eventSourceRef.current = null;
29+
}
30+
};
31+
}, [start]);
32+
33+
const handleStart = () => {
34+
setEvents([]);
35+
setStart(true);
36+
};
37+
38+
const handleClose = () => {
39+
if (eventSourceRef.current) {
40+
eventSourceRef.current.close();
41+
eventSourceRef.current = null;
42+
}
43+
setStart(false);
44+
};
45+
46+
return (
47+
<section>
48+
<div>
49+
<button onClick={handleStart} data-testid="start-button" disabled={start}>
50+
Start
51+
</button>
52+
<button onClick={handleClose} data-testid="close-button" disabled={!start}>
53+
Close
54+
</button>
55+
</div>
56+
{events.map((e, i) => (
57+
<div key={i}>
58+
Message {i}: {JSON.stringify(e)}
59+
</div>
60+
))}
61+
</section>
62+
);
63+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import SSE from "./_components/sse";
2+
3+
export const dynamic = "force-static";
4+
5+
export default function Page() {
6+
const date = new Date().toISOString();
7+
8+
return (
9+
<main>
10+
<h1 data-testid="date">{date}</h1>
11+
12+
<SSE />
13+
</main>
14+
);
15+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
test("Request Signal On Abort", async ({ page }) => {
4+
// First, get the initial date
5+
await page.goto("/signal");
6+
const initialDate = await page.getByTestId("date").textContent();
7+
expect(initialDate).toBeTruthy();
8+
9+
// Start the EventSource
10+
await page.getByTestId("start-button").click();
11+
const msg0 = page.getByText(`Message 0: {"number":0}`);
12+
await expect(msg0).toBeVisible();
13+
14+
// 2nd message shouldn't arrive yet
15+
let msg1 = page.getByText(`Message 1: {"number":1}`);
16+
await expect(msg1).not.toBeVisible();
17+
await page.waitForTimeout(2_000);
18+
// 2nd message should arrive after 2s
19+
msg1 = page.getByText(`Message 2: {"number":2}`);
20+
await expect(msg1).toBeVisible();
21+
22+
// 3rd message shouldn't arrive yet
23+
let msg3 = page.getByText(`Message 3: {"number":3}`);
24+
await expect(msg3).not.toBeVisible();
25+
await page.waitForTimeout(2_000);
26+
// 3rd message should arrive after 2s
27+
msg3 = page.getByText(`Message 3: {"number":3}`);
28+
await expect(msg3).toBeVisible();
29+
30+
// We then click the close button to close the EventSource and trigger the onabort eventz[]
31+
await page.getByTestId("close-button").click();
32+
33+
// Wait for revalidation to finish
34+
await page.waitForTimeout(4_000);
35+
36+
// Check that the onabort event got emitted and revalidated the page from a fetch
37+
await page.goto("/signal");
38+
const finalDate = await page.getByTestId("date").textContent();
39+
expect(finalDate).toBeTruthy();
40+
expect(new Date(finalDate!).getTime()).toBeGreaterThan(new Date(initialDate!).getTime());
41+
});

examples/playground15/open-next.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import { defineCloudflareConfig, type OpenNextConfig } from "@opennextjs/cloudflare";
22
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";
3+
import doQueue from "@opennextjs/cloudflare/overrides/queue/do-queue";
4+
import d1NextTagCache from "@opennextjs/cloudflare/overrides/tag-cache/d1-next-tag-cache";
35

46
export default {
57
...defineCloudflareConfig({
68
incrementalCache: r2IncrementalCache,
9+
queue: doQueue,
10+
tagCache: d1NextTagCache,
711
}),
812
cloudflare: {
913
skewProtection: {

examples/playground15/wrangler.jsonc

Lines changed: 29 additions & 2 deletions
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",
@@ -17,5 +17,32 @@
1717
],
1818
"vars": {
1919
"hello": "Hello World from the cloudflare context!"
20-
}
20+
},
21+
"services": [
22+
{
23+
"binding": "WORKER_SELF_REFERENCE",
24+
"service": "playground15"
25+
}
26+
],
27+
"durable_objects": {
28+
"bindings": [
29+
{
30+
"name": "NEXT_CACHE_DO_QUEUE",
31+
"class_name": "DOQueueHandler"
32+
}
33+
]
34+
},
35+
"migrations": [
36+
{
37+
"tag": "v1",
38+
"new_sqlite_classes": ["DOQueueHandler"]
39+
}
40+
],
41+
"d1_databases": [
42+
{
43+
"binding": "NEXT_TAG_CACHE_D1",
44+
"database_id": "db_id",
45+
"database_name": "db_name"
46+
}
47+
]
2148
}

packages/cloudflare/src/cli/templates/worker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export default {
5454
// @ts-expect-error: resolved by wrangler build
5555
const { handler } = await import("./server-functions/default/handler.mjs");
5656

57-
return handler(reqOrResp, env, ctx);
57+
return handler(reqOrResp, env, ctx, request.signal);
5858
});
5959
},
6060
} satisfies ExportedHandler<CloudflareEnv>;

0 commit comments

Comments
 (0)