diff --git a/.changeset/new-dolphins-sleep.md b/.changeset/new-dolphins-sleep.md new file mode 100644 index 000000000..d8831422a --- /dev/null +++ b/.changeset/new-dolphins-sleep.md @@ -0,0 +1,6 @@ +--- +"@opennextjs/aws": patch +--- + +add support for next/after +It can also be used to emulate vercel request context (the waitUntil) for lib that may rely on it on serverless env. It needs this env variable EMULATE_VERCEL_REQUEST_CONTEXT to be set to be enabled diff --git a/examples/app-router/app/api/after/revalidate/route.ts b/examples/app-router/app/api/after/revalidate/route.ts new file mode 100644 index 000000000..ec67cfcd3 --- /dev/null +++ b/examples/app-router/app/api/after/revalidate/route.ts @@ -0,0 +1,16 @@ +import { revalidateTag } from "next/cache"; +import { NextResponse, unstable_after as after } from "next/server"; + +export function POST() { + after( + () => + new Promise((resolve) => + setTimeout(() => { + revalidateTag("date"); + resolve(); + }, 5000), + ), + ); + + return NextResponse.json({ success: true }); +} diff --git a/examples/app-router/app/api/after/ssg/route.ts b/examples/app-router/app/api/after/ssg/route.ts new file mode 100644 index 000000000..2acf353fb --- /dev/null +++ b/examples/app-router/app/api/after/ssg/route.ts @@ -0,0 +1,12 @@ +import { unstable_cache } from "next/cache"; +import { NextResponse } from "next/server"; + +export const dynamic = "force-static"; + +export async function GET() { + const dateFn = unstable_cache(() => new Date().toISOString(), ["date"], { + tags: ["date"], + }); + const date = await dateFn(); + return NextResponse.json({ date }); +} diff --git a/examples/app-router/next.config.ts b/examples/app-router/next.config.ts index 8a85d80a7..66cb0d30a 100644 --- a/examples/app-router/next.config.ts +++ b/examples/app-router/next.config.ts @@ -17,6 +17,9 @@ const nextConfig: NextConfig = { }, ], }, + experimental: { + after: true, + }, redirects: async () => { return [ { diff --git a/packages/open-next/src/adapters/cache.ts b/packages/open-next/src/adapters/cache.ts index 7de1b883b..06da6ee8b 100644 --- a/packages/open-next/src/adapters/cache.ts +++ b/packages/open-next/src/adapters/cache.ts @@ -1,5 +1,3 @@ -import type { IncrementalCache, TagCache } from "types/overrides"; - import { isBinaryContentType } from "./binary"; import { debug, error, warn } from "./logger"; @@ -100,15 +98,6 @@ export function hasCacheExtension(key: string) { return CACHE_EXTENSION_REGEX.test(key); } -declare global { - var incrementalCache: IncrementalCache; - var tagCache: TagCache; - var disableDynamoDBCache: boolean; - var disableIncrementalCache: boolean; - var lastModified: Record; - var isNextAfter15: boolean; -} - function isFetchCache( options?: | boolean @@ -227,7 +216,7 @@ export default class S3Cache { // If some tags are stale we need to force revalidation return null; } - const requestId = globalThis.__als.getStore()?.requestId ?? ""; + const requestId = globalThis.__openNextAls.getStore()?.requestId ?? ""; globalThis.lastModified[requestId] = _lastModified; if (cacheData?.type === "route") { return { @@ -298,7 +287,7 @@ export default class S3Cache { } // This one might not even be necessary anymore // Better be safe than sorry - const detachedPromise = globalThis.__als + const detachedPromise = globalThis.__openNextAls .getStore() ?.pendingPromiseRunner.withResolvers(); try { diff --git a/packages/open-next/src/adapters/edge-adapter.ts b/packages/open-next/src/adapters/edge-adapter.ts index 3c4fecbe9..86e55a2ed 100644 --- a/packages/open-next/src/adapters/edge-adapter.ts +++ b/packages/open-next/src/adapters/edge-adapter.ts @@ -1,6 +1,7 @@ import type { ReadableStream } from "node:stream/web"; import type { InternalEvent, InternalResult } from "types/open-next"; +import { runWithOpenNextRequestContext } from "utils/promise"; import { emptyReadableStream } from "utils/stream"; // We import it like that so that the edge plugin can replace it @@ -11,58 +12,65 @@ import { convertToQueryString, } from "../core/routing/util"; -declare global { - var isEdgeRuntime: true; -} +globalThis.__openNextAls = new AsyncLocalStorage(); const defaultHandler = async ( internalEvent: InternalEvent, ): Promise => { globalThis.isEdgeRuntime = true; - const host = internalEvent.headers.host - ? `https://${internalEvent.headers.host}` - : "http://localhost:3000"; - const initialUrl = new URL(internalEvent.rawPath, host); - initialUrl.search = convertToQueryString(internalEvent.query); - const url = initialUrl.toString(); + // We run everything in the async local storage context so that it is available in edge runtime functions + return runWithOpenNextRequestContext( + { isISRRevalidation: false }, + async () => { + const host = internalEvent.headers.host + ? `https://${internalEvent.headers.host}` + : "http://localhost:3000"; + const initialUrl = new URL(internalEvent.rawPath, host); + initialUrl.search = convertToQueryString(internalEvent.query); + const url = initialUrl.toString(); - // @ts-expect-error - This is bundled - const handler = await import(`./middleware.mjs`); + // @ts-expect-error - This is bundled + const handler = await import(`./middleware.mjs`); - const response: Response = await handler.default({ - headers: internalEvent.headers, - method: internalEvent.method || "GET", - nextConfig: { - basePath: NextConfig.basePath, - i18n: NextConfig.i18n, - trailingSlash: NextConfig.trailingSlash, - }, - url, - body: convertBodyToReadableStream(internalEvent.method, internalEvent.body), - }); - const responseHeaders: Record = {}; - response.headers.forEach((value, key) => { - if (key.toLowerCase() === "set-cookie") { - responseHeaders[key] = responseHeaders[key] - ? [...responseHeaders[key], value] - : [value]; - } else { - responseHeaders[key] = value; - } - }); + const response: Response = await handler.default({ + headers: internalEvent.headers, + method: internalEvent.method || "GET", + nextConfig: { + basePath: NextConfig.basePath, + i18n: NextConfig.i18n, + trailingSlash: NextConfig.trailingSlash, + }, + url, + body: convertBodyToReadableStream( + internalEvent.method, + internalEvent.body, + ), + }); + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + if (key.toLowerCase() === "set-cookie") { + responseHeaders[key] = responseHeaders[key] + ? [...responseHeaders[key], value] + : [value]; + } else { + responseHeaders[key] = value; + } + }); - const body = - (response.body as ReadableStream) ?? emptyReadableStream(); + const body = + (response.body as ReadableStream) ?? emptyReadableStream(); - return { - type: "core", - statusCode: response.status, - headers: responseHeaders, - body: body, - // Do we need to handle base64 encoded response? - isBase64Encoded: false, - }; + return { + type: "core", + statusCode: response.status, + headers: responseHeaders, + body: body, + // Do we need to handle base64 encoded response? + isBase64Encoded: false, + }; + }, + ); }; export const handler = await createGenericHandler({ diff --git a/packages/open-next/src/adapters/logger.ts b/packages/open-next/src/adapters/logger.ts index 3328192f5..e05092196 100644 --- a/packages/open-next/src/adapters/logger.ts +++ b/packages/open-next/src/adapters/logger.ts @@ -1,9 +1,5 @@ import type { BaseOpenNextError } from "utils/error"; -declare global { - var openNextDebug: boolean; -} - export function debug(...args: any[]) { if (globalThis.openNextDebug) { console.log(...args); diff --git a/packages/open-next/src/adapters/middleware.ts b/packages/open-next/src/adapters/middleware.ts index b56930492..5ae669518 100644 --- a/packages/open-next/src/adapters/middleware.ts +++ b/packages/open-next/src/adapters/middleware.ts @@ -1,4 +1,5 @@ import type { InternalEvent, Origin } from "types/open-next"; +import { runWithOpenNextRequestContext } from "utils/promise"; import { debug } from "../adapters/logger"; import { createGenericHandler } from "../core/createGenericHandler"; @@ -11,6 +12,7 @@ import { import routingHandler from "../core/routingHandler"; globalThis.internalFetch = fetch; +globalThis.__openNextAls = new AsyncLocalStorage(); const defaultHandler = async (internalEvent: InternalEvent) => { const originResolver = await resolveOriginResolver( @@ -31,24 +33,30 @@ const defaultHandler = async (internalEvent: InternalEvent) => { ); //#endOverride - const result = await routingHandler(internalEvent); - if ("internalEvent" in result) { - debug("Middleware intercepted event", internalEvent); - let origin: Origin | false = false; - if (!result.isExternalRewrite) { - origin = await originResolver.resolve(result.internalEvent.rawPath); - } - return { - type: "middleware", - internalEvent: result.internalEvent, - isExternalRewrite: result.isExternalRewrite, - origin, - isISR: result.isISR, - }; - } - - debug("Middleware response", result); - return result; + // We run everything in the async local storage context so that it is available in the external middleware + return runWithOpenNextRequestContext( + { isISRRevalidation: internalEvent.headers["x-isr"] === "1" }, + async () => { + const result = await routingHandler(internalEvent); + if ("internalEvent" in result) { + debug("Middleware intercepted event", internalEvent); + let origin: Origin | false = false; + if (!result.isExternalRewrite) { + origin = await originResolver.resolve(result.internalEvent.rawPath); + } + return { + type: "middleware", + internalEvent: result.internalEvent, + isExternalRewrite: result.isExternalRewrite, + origin, + isISR: result.isISR, + }; + } + + debug("Middleware response", result); + return result; + }, + ); }; export const handler = await createGenericHandler({ diff --git a/packages/open-next/src/adapters/server-adapter.ts b/packages/open-next/src/adapters/server-adapter.ts index 486d8a4a3..46d2269db 100644 --- a/packages/open-next/src/adapters/server-adapter.ts +++ b/packages/open-next/src/adapters/server-adapter.ts @@ -12,9 +12,6 @@ setBuildIdEnv(); setNextjsServerWorkingDirectory(); // Because next is messing with fetch, we have to make sure that we use an untouched version of fetch -declare global { - var internalFetch: typeof fetch; -} globalThis.internalFetch = fetch; ///////////// diff --git a/packages/open-next/src/build/patch/patchedAsyncStorage.ts b/packages/open-next/src/build/patch/patchedAsyncStorage.ts index 64bbf6a51..70deae019 100644 --- a/packages/open-next/src/build/patch/patchedAsyncStorage.ts +++ b/packages/open-next/src/build/patch/patchedAsyncStorage.ts @@ -10,7 +10,7 @@ const staticGenerationAsyncStorage = { if (store) { store.isOnDemandRevalidate = store.isOnDemandRevalidate && - !globalThis.__als.getStore().isISRRevalidation; + !globalThis.__openNextAls.getStore().isISRRevalidation; } return store; }, diff --git a/packages/open-next/src/core/createGenericHandler.ts b/packages/open-next/src/core/createGenericHandler.ts index 4a78d97b4..1870b5033 100644 --- a/packages/open-next/src/core/createGenericHandler.ts +++ b/packages/open-next/src/core/createGenericHandler.ts @@ -10,10 +10,6 @@ import type { OpenNextHandler } from "types/overrides"; import { debug } from "../adapters/logger"; import { resolveConverter, resolveWrapper } from "./resolve"; -declare global { - var openNextConfig: Partial; -} - type HandlerType = | "imageOptimization" | "revalidate" diff --git a/packages/open-next/src/core/createMainHandler.ts b/packages/open-next/src/core/createMainHandler.ts index 44ba7ab47..3c96d7f7a 100644 --- a/packages/open-next/src/core/createMainHandler.ts +++ b/packages/open-next/src/core/createMainHandler.ts @@ -1,8 +1,4 @@ -import type { AsyncLocalStorage } from "node:async_hooks"; - import type { OpenNextConfig } from "types/open-next"; -import type { IncrementalCache, Queue } from "types/overrides"; -import type { DetachedPromiseRunner } from "utils/promise"; import { debug } from "../adapters/logger"; import { generateUniqueId } from "../adapters/util"; @@ -15,19 +11,6 @@ import { resolveWrapper, } from "./resolve"; -declare global { - var queue: Queue; - var incrementalCache: IncrementalCache; - var fnName: string | undefined; - var serverId: string; - var __als: AsyncLocalStorage<{ - requestId: string; - pendingPromiseRunner: DetachedPromiseRunner; - isISRRevalidation?: boolean; - mergeHeadersPriority?: "middleware" | "handler"; - }>; -} - export async function createMainHandler() { // @ts-expect-error `./open-next.config.mjs` exists only in the build output const config: OpenNextConfig = await import("./open-next.config.mjs").then( diff --git a/packages/open-next/src/core/edgeFunctionHandler.ts b/packages/open-next/src/core/edgeFunctionHandler.ts index 9924b9b45..c539d554a 100644 --- a/packages/open-next/src/core/edgeFunctionHandler.ts +++ b/packages/open-next/src/core/edgeFunctionHandler.ts @@ -1,54 +1,6 @@ // Necessary files will be imported here with banner in esbuild -import type { OutgoingHttpHeaders } from "http"; - -interface RequestData { - geo?: { - city?: string; - country?: string; - region?: string; - latitude?: string; - longitude?: string; - }; - headers: OutgoingHttpHeaders; - ip?: string; - method: string; - nextConfig?: { - basePath?: string; - i18n?: any; - trailingSlash?: boolean; - }; - page?: { - name?: string; - params?: { [key: string]: string | string[] }; - }; - url: string; - body?: ReadableStream; - signal: AbortSignal; -} - -interface Entries { - [k: string]: { - default: (props: { page: string; request: RequestData }) => Promise<{ - response: Response; - waitUntil: Promise; - }>; - }; -} -declare global { - var _ENTRIES: Entries; - var _ROUTES: EdgeRoute[]; - var __storage__: Map; - var AsyncContext: any; - //@ts-ignore - var AsyncLocalStorage: any; -} - -export interface EdgeRoute { - name: string; - page: string; - regex: string[]; -} +import type { RequestData } from "types/global"; type EdgeRequest = Omit; diff --git a/packages/open-next/src/core/requestHandler.ts b/packages/open-next/src/core/requestHandler.ts index 8dc393443..ffce10383 100644 --- a/packages/open-next/src/core/requestHandler.ts +++ b/packages/open-next/src/core/requestHandler.ts @@ -3,7 +3,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import type { OpenNextNodeResponse, StreamCreator } from "http/index.js"; import { IncomingMessage } from "http/index.js"; import type { InternalEvent, InternalResult } from "types/open-next"; -import { DetachedPromiseRunner } from "utils/promise"; +import { runWithOpenNextRequestContext } from "utils/promise"; import { debug, error, warn } from "../adapters/logger"; import { patchAsyncStorage } from "./patchAsyncStorage"; @@ -16,12 +16,7 @@ import routingHandler, { import { requestHandler, setNextjsPrebundledReact } from "./util"; // This is used to identify requests in the cache -globalThis.__als = new AsyncLocalStorage<{ - requestId: string; - pendingPromiseRunner: DetachedPromiseRunner; - isISRRevalidation?: boolean; - mergeHeadersPriority?: "middleware" | "handler"; -}>(); +globalThis.__openNextAls = new AsyncLocalStorage(); patchAsyncStorage(); @@ -29,94 +24,92 @@ export async function openNextHandler( internalEvent: InternalEvent, responseStreaming?: StreamCreator, ): Promise { - if (internalEvent.headers["x-forwarded-host"]) { - internalEvent.headers.host = internalEvent.headers["x-forwarded-host"]; - } - debug("internalEvent", internalEvent); - - let preprocessResult: InternalResult | MiddlewareOutputEvent = { - internalEvent: internalEvent, - isExternalRewrite: false, - origin: false, - isISR: false, - }; - - //#override withRouting - try { - preprocessResult = await routingHandler(internalEvent); - } catch (e) { - warn("Routing failed.", e); - } - //#endOverride + // We run everything in the async local storage context so that it is available in the middleware as well as in NextServer + return runWithOpenNextRequestContext( + { isISRRevalidation: internalEvent.headers["x-isr"] === "1" }, + async () => { + if (internalEvent.headers["x-forwarded-host"]) { + internalEvent.headers.host = internalEvent.headers["x-forwarded-host"]; + } + debug("internalEvent", internalEvent); - const headers = - "type" in preprocessResult - ? preprocessResult.headers - : preprocessResult.internalEvent.headers; + let preprocessResult: InternalResult | MiddlewareOutputEvent = { + internalEvent: internalEvent, + isExternalRewrite: false, + origin: false, + isISR: false, + }; - const overwrittenResponseHeaders: Record = {}; + //#override withRouting + try { + preprocessResult = await routingHandler(internalEvent); + } catch (e) { + warn("Routing failed.", e); + } + //#endOverride + + const headers = + "type" in preprocessResult + ? preprocessResult.headers + : preprocessResult.internalEvent.headers; + + const overwrittenResponseHeaders: Record = {}; + + for (const [rawKey, value] of Object.entries(headers)) { + if (!rawKey.startsWith(MIDDLEWARE_HEADER_PREFIX)) { + continue; + } + const key = rawKey.slice(MIDDLEWARE_HEADER_PREFIX_LEN); + overwrittenResponseHeaders[key] = value; + headers[key] = value; + delete headers[rawKey]; + } - for (const [rawKey, value] of Object.entries(headers)) { - if (!rawKey.startsWith(MIDDLEWARE_HEADER_PREFIX)) { - continue; - } - const key = rawKey.slice(MIDDLEWARE_HEADER_PREFIX_LEN); - overwrittenResponseHeaders[key] = value; - headers[key] = value; - delete headers[rawKey]; - } + if ("type" in preprocessResult) { + // response is used only in the streaming case + if (responseStreaming) { + const response = createServerResponse( + internalEvent, + headers, + responseStreaming, + ); + response.statusCode = preprocessResult.statusCode; + response.flushHeaders(); + const [bodyToConsume, bodyToReturn] = preprocessResult.body.tee(); + for await (const chunk of bodyToConsume) { + response.write(chunk); + } + response.end(); + preprocessResult.body = bodyToReturn; + } + return preprocessResult; + } + const preprocessedEvent = preprocessResult.internalEvent; + debug("preprocessedEvent", preprocessedEvent); + const reqProps = { + method: preprocessedEvent.method, + url: preprocessedEvent.url, + //WORKAROUND: We pass this header to the serverless function to mimic a prefetch request which will not trigger revalidation since we handle revalidation differently + // There is 3 way we can handle revalidation: + // 1. We could just let the revalidation go as normal, but due to race condtions the revalidation will be unreliable + // 2. We could alter the lastModified time of our cache to make next believe that the cache is fresh, but this could cause issues with stale data since the cdn will cache the stale data as if it was fresh + // 3. OUR CHOICE: We could pass a purpose prefetch header to the serverless function to make next believe that the request is a prefetch request and not trigger revalidation (This could potentially break in the future if next changes the behavior of prefetch requests) + headers: { ...headers, purpose: "prefetch" }, + body: preprocessedEvent.body, + remoteAddress: preprocessedEvent.remoteAddress, + }; - if ("type" in preprocessResult) { - // response is used only in the streaming case - if (responseStreaming) { - const response = createServerResponse( - internalEvent, - headers, - responseStreaming, - ); - response.statusCode = preprocessResult.statusCode; - response.flushHeaders(); - const [bodyToConsume, bodyToReturn] = preprocessResult.body.tee(); - for await (const chunk of bodyToConsume) { - response.write(chunk); + const mergeHeadersPriority = globalThis.openNextConfig.dangerous + ?.headersAndCookiesPriority + ? globalThis.openNextConfig.dangerous.headersAndCookiesPriority( + preprocessedEvent, + ) + : "middleware"; + const store = globalThis.__openNextAls.getStore(); + if (store) { + store.mergeHeadersPriority = mergeHeadersPriority; } - response.end(); - preprocessResult.body = bodyToReturn; - } - return preprocessResult; - } - const preprocessedEvent = preprocessResult.internalEvent; - debug("preprocessedEvent", preprocessedEvent); - const reqProps = { - method: preprocessedEvent.method, - url: preprocessedEvent.url, - //WORKAROUND: We pass this header to the serverless function to mimic a prefetch request which will not trigger revalidation since we handle revalidation differently - // There is 3 way we can handle revalidation: - // 1. We could just let the revalidation go as normal, but due to race conditions the revalidation will be unreliable - // 2. We could alter the lastModified time of our cache to make next believe that the cache is fresh, but this could cause issues with stale data since the cdn will cache the stale data as if it was fresh - // 3. OUR CHOICE: We could pass a purpose prefetch header to the serverless function to make next believe that the request is a prefetch request and not trigger revalidation (This could potentially break in the future if next changes the behavior of prefetch requests) - headers: { ...headers, purpose: "prefetch" }, - body: preprocessedEvent.body, - remoteAddress: preprocessedEvent.remoteAddress, - }; - const requestId = Math.random().toString(36); - const pendingPromiseRunner = new DetachedPromiseRunner(); - const isISRRevalidation = headers["x-isr"] === "1"; - const mergeHeadersPriority = globalThis.openNextConfig.dangerous - ?.headersAndCookiesPriority - ? globalThis.openNextConfig.dangerous.headersAndCookiesPriority( - preprocessedEvent, - ) - : "middleware"; - const internalResult = await globalThis.__als.run( - { - requestId, - pendingPromiseRunner, - isISRRevalidation, - mergeHeadersPriority, - }, - async () => { const preprocessedResult = preprocessResult as MiddlewareOutputEvent; const req = new IncomingMessage(reqProps); const res = createServerResponse( @@ -132,25 +125,30 @@ export async function openNextHandler( preprocessedResult.isExternalRewrite, ); - const { statusCode, headers, isBase64Encoded, body } = convertRes(res); + const { + statusCode, + headers: responseHeaders, + isBase64Encoded, + body, + } = convertRes(res); const internalResult = { type: internalEvent.type, statusCode, - headers, + headers: responseHeaders, body, isBase64Encoded, }; + const requestId = store?.requestId; - // reset lastModified. We need to do this to avoid memory leaks - delete globalThis.lastModified[requestId]; - - await pendingPromiseRunner.await(); + if (requestId) { + // reset lastModified. We need to do this to avoid memory leaks + delete globalThis.lastModified[requestId]; + } return internalResult; }, ); - return internalResult; } async function processRequest( diff --git a/packages/open-next/src/core/routing/util.ts b/packages/open-next/src/core/routing/util.ts index bc3ebce27..312be2e54 100644 --- a/packages/open-next/src/core/routing/util.ts +++ b/packages/open-next/src/core/routing/util.ts @@ -264,12 +264,6 @@ export async function proxyRequest( // res.end(await result.text()); } -declare global { - var openNextDebug: boolean; - var openNextVersion: string; - var lastModified: Record; -} - enum CommonHeaders { CACHE_CONTROL = "cache-control", NEXT_CACHE = "x-nextjs-cache", @@ -328,7 +322,8 @@ export function addOpenNextHeader(headers: OutgoingHttpHeaders) { } if (globalThis.openNextDebug) { headers["X-OpenNext-Version"] = globalThis.openNextVersion; - headers["X-OpenNext-RequestId"] = globalThis.__als.getStore()?.requestId; + headers["X-OpenNext-RequestId"] = + globalThis.__openNextAls.getStore()?.requestId; } } @@ -368,7 +363,7 @@ export async function revalidateIfRequired( try { const hash = (str: string) => crypto.createHash("md5").update(str).digest("hex"); - const requestId = globalThis.__als.getStore()?.requestId ?? ""; + const requestId = globalThis.__openNextAls.getStore()?.requestId ?? ""; const lastModified = globalThis.lastModified[requestId] > 0 @@ -444,7 +439,7 @@ export function fixISRHeaders(headers: OutgoingHttpHeaders) { "private, no-cache, no-store, max-age=0, must-revalidate"; return; } - const requestId = globalThis.__als.getStore()?.requestId ?? ""; + const requestId = globalThis.__openNextAls.getStore()?.requestId ?? ""; const _lastModified = globalThis.lastModified[requestId] ?? 0; if (headers[CommonHeaders.NEXT_CACHE] === "HIT" && _lastModified > 0) { // calculate age diff --git a/packages/open-next/src/http/openNextResponse.ts b/packages/open-next/src/http/openNextResponse.ts index d2c89cf73..6b3c370ff 100644 --- a/packages/open-next/src/http/openNextResponse.ts +++ b/packages/open-next/src/http/openNextResponse.ts @@ -89,7 +89,7 @@ export class OpenNextNodeResponse extends Transform implements ServerResponse { } // In some cases we might not have a store i.e. for example in the image optimization function // We may want to reconsider this in the future, it might be intersting to have access to this store everywhere - globalThis.__als + globalThis.__openNextAls ?.getStore() ?.pendingPromiseRunner.add(onEnd(this.headers)); const bodyLength = this.getBody().length; @@ -161,7 +161,8 @@ export class OpenNextNodeResponse extends Transform implements ServerResponse { // Initial headers should be merged with the new headers // These initial headers are the one created either in the middleware or in next.config.js const mergeHeadersPriority = - globalThis.__als?.getStore()?.mergeHeadersPriority ?? "middleware"; + globalThis.__openNextAls?.getStore()?.mergeHeadersPriority ?? + "middleware"; if (this.initialHeaders) { this.headers = mergeHeadersPriority === "middleware" diff --git a/packages/open-next/src/overrides/wrappers/cloudflare.ts b/packages/open-next/src/overrides/wrappers/cloudflare.ts index 9320e3a99..6d52fde36 100644 --- a/packages/open-next/src/overrides/wrappers/cloudflare.ts +++ b/packages/open-next/src/overrides/wrappers/cloudflare.ts @@ -11,13 +11,22 @@ const cfPropNameToHeaderName = { longitude: "x-open-next-longitude", }; +interface WorkerContext { + waitUntil: (promise: Promise) => void; +} + const handler: WrapperHandler< InternalEvent, InternalResult | ({ type: "middleware" } & MiddlewareOutputEvent) > = async (handler, converter) => - async (request: Request, env: Record): Promise => { + async ( + request: Request, + env: Record, + ctx: WorkerContext, + ): Promise => { globalThis.process = process; + globalThis.openNextWaitUntil = ctx.waitUntil; // Set the environment variables // Cloudflare suggests to not override the process.env object but instead apply the values to it diff --git a/packages/open-next/src/types/global.ts b/packages/open-next/src/types/global.ts new file mode 100644 index 000000000..2f9191a31 --- /dev/null +++ b/packages/open-next/src/types/global.ts @@ -0,0 +1,197 @@ +import type { AsyncLocalStorage } from "node:async_hooks"; +import type { OutgoingHttpHeaders } from "node:http"; + +import type { IncrementalCache, Queue, TagCache } from "types/overrides"; + +import type { DetachedPromiseRunner } from "../utils/promise"; +import type { OpenNextConfig } from "./open-next"; + +export interface RequestData { + geo?: { + city?: string; + country?: string; + region?: string; + latitude?: string; + longitude?: string; + }; + headers: OutgoingHttpHeaders; + ip?: string; + method: string; + nextConfig?: { + basePath?: string; + i18n?: any; + trailingSlash?: boolean; + }; + page?: { + name?: string; + params?: { [key: string]: string | string[] }; + }; + url: string; + body?: ReadableStream; + signal: AbortSignal; +} + +interface Entries { + [k: string]: { + default: (props: { page: string; request: RequestData }) => Promise<{ + response: Response; + waitUntil: Promise; + }>; + }; +} + +export interface EdgeRoute { + name: string; + page: string; + regex: string[]; +} + +interface OpenNextRequestContext { + requestId: string; + pendingPromiseRunner: DetachedPromiseRunner; + isISRRevalidation?: boolean; + mergeHeadersPriority?: "middleware" | "handler"; +} + +declare global { + // Needed in the cache adapter + /** + * The cache adapter for incremental static regeneration. + * Only available in main functions or in the external middleware with `enableCacheInterception` set to `true`. + * Defined in `createMainHandler` or in `adapter/middleware.ts`. + */ + var incrementalCache: IncrementalCache; + /** + * The cache adapter for the tag cache. + * Only available in main functions, the initializationFunction or in the external middleware with `enableCacheInterception` set to `true`. + * Defined in `createMainHandler` or in `adapter/middleware.ts`. + */ + var tagCache: TagCache; + /** + * A boolean that indicates if the DynamoDB cache is disabled. + * TODO: Remove this, we already have access to the config file + * Defined in esbuild banner for the cache adapter. + */ + var disableDynamoDBCache: boolean; + /** + * A boolean that indicates if the incremental cache is disabled. + * TODO: Remove this, we already have access to the config file + * Defined in esbuild banner for the cache adapter. + */ + var disableIncrementalCache: boolean; + + /** + * An object that contains the last modified time of the pages. + * Only available in main functions. + * TODO: Integrate this directly in the AsyncLocalStorage context + * Defined in `createMainHandler`. + */ + var lastModified: Record; + + /** + * A boolean that indicates if Next is V15 or higher. + * Only available in the cache adapter. + * Defined in the esbuild banner for the cache adapter. + */ + var isNextAfter15: boolean; + + /** + * A boolean that indicates if the runtime is Edge. + * Only available in `edge` runtime functions (i.e. external middleware or function with edge runtime). + * Defined in the `edge-adapter.ts`. + */ + var isEdgeRuntime: true; + + /** + * A boolean that indicates if we are running in debug mode. + * Available in all functions. + * Defined in the esbuild banner. + */ + var openNextDebug: boolean; + + /** + * The fetch function that should be used to make requests during the execution of the function. + * Used to bypass Next intercepting and caching the fetch calls. Only available in main functions. + * Defined in the `server-adapter.ts` and in `adapters/middleware.ts`. + */ + var internalFetch: typeof fetch; + + /** + * The Open Next configuration object. + * Available in all functions. + * Defined in the `createMainHandler` or in the `createGenericHandler`. + */ + var openNextConfig: Partial; + + /** + * The name of the function that is currently being executed. + * Only available in main functions. + * Defined in the `createMainHandler`. + */ + var fnName: string | undefined; + /** + * The unique identifier of the server. + * Only available in main functions. + * Defined in the `createMainHandler`. + */ + var serverId: string; + + /** + * The AsyncLocalStorage instance that is used to store the request context. + * Only available in main, middleware and edge functions. + * TODO: should be available everywhere in the future. + * Defined in `requestHandler.ts`, `middleware.ts` and `edge-adapter.ts`. + */ + var __openNextAls: AsyncLocalStorage; + + /** + * The function that is used to run background tasks even after the response has been sent. + * This one is defined by the wrapper function as most of them don't need or support this feature. + * If not present, all the awaiting promises will be resolved before sending the response. + */ + var openNextWaitUntil: ((promise: Promise) => void) | undefined; + + /** + * The entries object that contains the functions that are available in the function. + * Only available in edge runtime functions. + * Defined in the esbuild edge plugin. + */ + var _ENTRIES: Entries; + /** + * The routes object that contains the routes that are available in the function. + * Only available in edge runtime functions. + * Defined in the esbuild edge plugin. + */ + var _ROUTES: EdgeRoute[]; + /** + * A map that is used in the edge runtime. + * Only available in edge runtime functions. + */ + var __storage__: Map; + /** + * AsyncContext available globally in the edge runtime. + * Only available in edge runtime functions. + */ + var AsyncContext: any; + /** + * AsyncLocalStorage available globally in the edge runtime. + * Only available in edge runtime functions. + * Defined in createEdgeBundle. + */ + // biome-ignore lint/suspicious/noRedeclare: This is only needed in the edge runtime + var AsyncLocalStorage: any; + + /** + * The version of the Open Next runtime. + * Available everywhere. + * Defined in the esbuild banner. + */ + var openNextVersion: string; + + /** + * The queue that is used to handle ISR revalidation requests. + * Only available in main functions and in the external middleware with `enableCacheInterception` set to `true`. + * Defined in `createMainHandler` or in `adapter/middleware.ts`. + */ + var queue: Queue; +} diff --git a/packages/open-next/src/utils/promise.ts b/packages/open-next/src/utils/promise.ts index d18d3410f..e80f83acd 100644 --- a/packages/open-next/src/utils/promise.ts +++ b/packages/open-next/src/utils/promise.ts @@ -58,3 +58,64 @@ export class DetachedPromiseRunner { }); } } + +async function awaitAllDetachedPromise() { + const promisesToAwait = + globalThis.__openNextAls.getStore()?.pendingPromiseRunner.await() ?? + Promise.resolve(); + if (globalThis.openNextWaitUntil) { + globalThis.openNextWaitUntil(promisesToAwait); + return; + } + await promisesToAwait; +} + +function provideNextAfterProvider() { + /** This should be considered unstable until `unstable_after` is stablized. */ + const NEXT_REQUEST_CONTEXT_SYMBOL = Symbol.for("@next/request-context"); + + // This is needed by some lib that relies on the vercel request context to properly await stuff. + // Remove this when vercel builder is updated to provide '@next/request-context'. + const VERCEL_REQUEST_CONTEXT_SYMBOL = Symbol.for("@vercel/request-context"); + + const openNextStoreContext = globalThis.__openNextAls.getStore(); + + const waitUntil = + globalThis.openNextWaitUntil ?? + ((promise: Promise) => + openNextStoreContext?.pendingPromiseRunner.add(promise)); + + const nextAfterContext = { + get: () => ({ + waitUntil, + }), + }; + + //@ts-expect-error + globalThis[NEXT_REQUEST_CONTEXT_SYMBOL] = nextAfterContext; + // We probably want to avoid providing this everytime since some lib may incorrectly think they are running in Vercel + // It may break stuff, but at the same time it will allow libs like `@vercel/otel` to work as expected + if (process.env.EMULATE_VERCEL_REQUEST_CONTEXT) { + //@ts-expect-error + globalThis[VERCEL_REQUEST_CONTEXT_SYMBOL] = nextAfterContext; + } +} + +export function runWithOpenNextRequestContext( + { isISRRevalidation }: { isISRRevalidation: boolean }, + fn: () => Promise, +): Promise { + return globalThis.__openNextAls.run( + { + requestId: Math.random().toString(36), + pendingPromiseRunner: new DetachedPromiseRunner(), + isISRRevalidation, + }, + async () => { + provideNextAfterProvider(); + const result = await fn(); + await awaitAllDetachedPromise(); + return result; + }, + ); +} diff --git a/packages/tests-e2e/tests/appRouter/after.test.ts b/packages/tests-e2e/tests/appRouter/after.test.ts new file mode 100644 index 000000000..c41b4c051 --- /dev/null +++ b/packages/tests-e2e/tests/appRouter/after.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "@playwright/test"; + +test("Next after", async ({ request }) => { + const initialSSG = await request.get("/api/after/ssg"); + expect(initialSSG.status()).toEqual(200); + const initialSSGJson = await initialSSG.json(); + + // We then fire a post request that will revalidate the SSG page 5 seconds after, but should respond immediately + const dateNow = Date.now(); + const revalidateSSG = await request.post("/api/after/revalidate"); + expect(revalidateSSG.status()).toEqual(200); + const revalidateSSGJson = await revalidateSSG.json(); + expect(revalidateSSGJson.success).toEqual(true); + // This request should take less than 5 seconds to respond + expect(Date.now() - dateNow).toBeLessThan(5000); + + // We want to immediately check if the SSG page has been revalidated, it should not have been + const notRevalidatedSSG = await request.get("/api/after/ssg"); + expect(notRevalidatedSSG.status()).toEqual(200); + const notRevalidatedSSGJson = await notRevalidatedSSG.json(); + expect(notRevalidatedSSGJson.date).toEqual(initialSSGJson.date); + + // We then wait for 5 seconds to ensure the SSG page has been revalidated + await new Promise((resolve) => setTimeout(resolve, 5000)); + const revalidatedSSG = await request.get("/api/after/ssg"); + expect(revalidatedSSG.status()).toEqual(200); + const revalidatedSSGJson = await revalidatedSSG.json(); + expect(revalidatedSSGJson.date).not.toEqual(initialSSGJson.date); +}); diff --git a/packages/tests-unit/tests/adapters/cache.test.ts b/packages/tests-unit/tests/adapters/cache.test.ts index f24b6179b..242c4cb53 100644 --- a/packages/tests-unit/tests/adapters/cache.test.ts +++ b/packages/tests-unit/tests/adapters/cache.test.ts @@ -2,6 +2,11 @@ import S3Cache, { hasCacheExtension } from "@opennextjs/aws/adapters/cache.js"; import { vi } from "vitest"; +declare global { + var disableIncrementalCache: boolean; + var isNextAfter15: boolean; +} + describe("hasCacheExtension", () => { it("Should returns true if has an extension and it is a CacheExtension", () => { expect(hasCacheExtension("hello.cache")).toBeTruthy(); @@ -51,7 +56,7 @@ describe("S3Cache", () => { }; globalThis.tagCache = tagCache; - globalThis.__als = { + globalThis.__openNextAls = { getStore: vi.fn().mockReturnValue({ requestId: "123", pendingPromiseRunner: { diff --git a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts index 8dfd7346e..f460e7bc3 100644 --- a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts +++ b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts @@ -68,12 +68,14 @@ const queue = { send: vi.fn(), }; -globalThis.incrementalCache = incrementalCache; -globalThis.tagCache = tagCache; - declare global { var queue: Queue; + var incrementalCache: any; + var tagCache: any; } + +globalThis.incrementalCache = incrementalCache; +globalThis.tagCache = tagCache; globalThis.queue = queue; beforeEach(() => { diff --git a/packages/tests-unit/tests/core/routing/util.test.ts b/packages/tests-unit/tests/core/routing/util.test.ts index 086e52487..b90d69f48 100644 --- a/packages/tests-unit/tests/core/routing/util.test.ts +++ b/packages/tests-unit/tests/core/routing/util.test.ts @@ -26,7 +26,10 @@ vi.mock("@opennextjs/aws/adapters/config/index.js", () => ({ })); declare global { - var __als: any; + var __openNextAls: any; + var lastModified: any; + var openNextDebug: boolean; + var openNextVersion: string; } type Res = { @@ -528,7 +531,7 @@ describe("addOpenNextHeader", () => { delete config.NextConfig["poweredByHeader"]; globalThis.openNextDebug = false; globalThis.openNextVersion = "1.0.0"; - globalThis.__als = { + globalThis.__openNextAls = { getStore: () => ({ requestId: "123", }), @@ -580,7 +583,7 @@ describe("revalidateIfRequired", () => { name: "mock", }; - globalThis.__als = { + globalThis.__openNextAls = { getStore: vi.fn(), }; @@ -625,7 +628,7 @@ describe("revalidateIfRequired", () => { describe("fixISRHeaders", () => { beforeEach(() => { vi.useFakeTimers().setSystemTime("2024-01-02T00:00:00Z"); - globalThis.__als = { + globalThis.__openNextAls = { getStore: () => ({ requestId: "123", }),