diff --git a/examples/pages-router/open-next.config.ts b/examples/pages-router/open-next.config.ts index 54e09d87d..f5f7c73b9 100644 --- a/examples/pages-router/open-next.config.ts +++ b/examples/pages-router/open-next.config.ts @@ -1,5 +1,9 @@ const config = { - default: {}, + default: { + override: { + wrapper: "aws-lambda-streaming", + }, + }, functions: {}, buildCommand: "npx turbo build", }; diff --git a/examples/pages-router/src/pages/api/streaming/index.ts b/examples/pages-router/src/pages/api/streaming/index.ts new file mode 100644 index 000000000..6a61fa590 --- /dev/null +++ b/examples/pages-router/src/pages/api/streaming/index.ts @@ -0,0 +1,47 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +const SADE_SMOOTH_OPERATOR_LYRIC = `Diamond life, lover boy +He move in space with minimum waste and maximum joy +City lights and business nights +When you require streetcar desire for higher heights +No place for beginners or sensitive hearts +When sentiment is left to chance +No place to be ending but somewhere to start +No need to ask, he's a smooth operator +Smooth operator, smooth operator +Smooth operator`; + +function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if (req.method !== "GET") { + return res.status(405).json({ message: "Method not allowed" }); + } + + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("Cache-Control", "no-cache, no-transform"); + res.setHeader("Transfer-Encoding", "chunked"); + + res.write( + `data: ${JSON.stringify({ type: "start", model: "ai-lyric-model" })}\n\n`, + ); + await sleep(1000); + + const lines = SADE_SMOOTH_OPERATOR_LYRIC.split("\n"); + for (const line of lines) { + res.write(`data: ${JSON.stringify({ type: "content", body: line })}\n\n`); + await sleep(1000); + } + + res.write(`data: ${JSON.stringify({ type: "complete" })}\n\n`); + + res.end(); +} diff --git a/examples/pages-router/src/pages/sse/index.tsx b/examples/pages-router/src/pages/sse/index.tsx new file mode 100644 index 000000000..dbc5f8ee9 --- /dev/null +++ b/examples/pages-router/src/pages/sse/index.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useEffect, useState } from "react"; + +type Event = { + type: "start" | "content" | "complete"; + model?: string; + body?: string; +}; + +export default function SSE() { + const [events, setEvents] = useState([]); + const [finished, setFinished] = useState(false); + + useEffect(() => { + const e = new EventSource("/api/streaming"); + + e.onmessage = (msg) => { + console.log(msg); + try { + const data = JSON.parse(msg.data) as Event; + if (data.type === "complete") { + e.close(); + setFinished(true); + } + if (data.type === "content") { + setEvents((prev) => prev.concat(data)); + } + } catch (err) { + console.error(err, msg); + } + }; + }, []); + + return ( +
+

+ Sade - Smooth Operator +

+
+ {events.map((e, i) => ( +

+ {e.body} +

+ ))} +
+ {finished && ( + + )} +
+ ); +} diff --git a/examples/sst/stacks/PagesRouter.ts b/examples/sst/stacks/PagesRouter.ts index 75ddd227b..8b699f9dc 100644 --- a/examples/sst/stacks/PagesRouter.ts +++ b/examples/sst/stacks/PagesRouter.ts @@ -3,6 +3,14 @@ import { OpenNextCdkReferenceImplementation } from "./OpenNextReferenceImplement export function PagesRouter({ stack }) { const site = new OpenNextCdkReferenceImplementation(stack, "pagesrouter", { path: "../pages-router", + /* + * We need to set this environment variable to not break other E2E tests that have an empty body. (i.e: /redirect) + * https://opennext.js.org/aws/common_issues#empty-body-in-response-when-streaming-in-aws-lambda + * + */ + environment: { + OPEN_NEXT_FORCE_NON_EMPTY_RESPONSE: "true", + }, }); // const site = new NextjsSite(stack, "pagesrouter", { // path: "../pages-router", diff --git a/packages/tests-e2e/tests/pagesRouter/streaming.test.ts b/packages/tests-e2e/tests/pagesRouter/streaming.test.ts new file mode 100644 index 000000000..ebfe1a57d --- /dev/null +++ b/packages/tests-e2e/tests/pagesRouter/streaming.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "@playwright/test"; + +const SADE_SMOOTH_OPERATOR_LYRIC = `Diamond life, lover boy +He move in space with minimum waste and maximum joy +City lights and business nights +When you require streetcar desire for higher heights +No place for beginners or sensitive hearts +When sentiment is left to chance +No place to be ending but somewhere to start +No need to ask, he's a smooth operator +Smooth operator, smooth operator +Smooth operator`; + +test("streaming should work in api route", async ({ page }) => { + await page.goto("/sse"); + + // wait for first line to be present + await page.getByTestId("line").first().waitFor(); + const initialLines = await page.getByTestId("line").count(); + // fail if all lines appear at once + // this is a safeguard to ensure that the response is streamed and not buffered all at once + expect(initialLines).toBe(1); + + const seenLines: Array<{ line: string; time: number }> = []; + const startTime = Date.now(); + + // we loop until we see all lines + while (seenLines.length < SADE_SMOOTH_OPERATOR_LYRIC.split("\n").length) { + const lines = await page.getByTestId("line").all(); + if (lines.length > seenLines.length) { + expect(lines.length).toBe(seenLines.length + 1); + const newLine = lines[lines.length - 1]; + seenLines.push({ + line: await newLine.innerText(), + time: Date.now() - startTime, + }); + } + // wait for a bit before checking again + await page.waitForTimeout(200); + } + + expect(seenLines.map((n) => n.line)).toEqual( + SADE_SMOOTH_OPERATOR_LYRIC.split("\n"), + ); + for (let i = 1; i < seenLines.length; i++) { + expect(seenLines[i].time - seenLines[i - 1].time).toBeGreaterThan(500); + } + + await expect(page.getByTestId("video")).toBeVisible(); +});