diff --git a/.changeset/late-dodos-attack.md b/.changeset/late-dodos-attack.md new file mode 100644 index 000000000..ed5d0b41f --- /dev/null +++ b/.changeset/late-dodos-attack.md @@ -0,0 +1,5 @@ +--- +"@opennextjs/aws": minor +--- + +Add an asset resolver diff --git a/packages/open-next/src/adapters/middleware.ts b/packages/open-next/src/adapters/middleware.ts index d6620dddd..5f0985bf0 100644 --- a/packages/open-next/src/adapters/middleware.ts +++ b/packages/open-next/src/adapters/middleware.ts @@ -9,6 +9,7 @@ import type { OpenNextHandlerOptions } from "types/overrides"; import { debug, error } from "../adapters/logger"; import { createGenericHandler } from "../core/createGenericHandler"; import { + resolveAssetResolver, resolveIncrementalCache, resolveOriginResolver, resolveProxyRequest, @@ -29,25 +30,22 @@ const defaultHandler = async ( internalEvent: InternalEvent, options?: OpenNextHandlerOptions, ): Promise => { - const originResolver = await resolveOriginResolver( - globalThis.openNextConfig.middleware?.originResolver, - ); + const config = globalThis.openNextConfig.middleware; + const originResolver = await resolveOriginResolver(config?.originResolver); const externalRequestProxy = await resolveProxyRequest( - globalThis.openNextConfig.middleware?.override?.proxyExternalRequest, + config?.override?.proxyExternalRequest, ); + const assetResolver = await resolveAssetResolver(config?.assetResolver); + //#override includeCacheInMiddleware - globalThis.tagCache = await resolveTagCache( - globalThis.openNextConfig.middleware?.override?.tagCache, - ); + globalThis.tagCache = await resolveTagCache(config?.override?.tagCache); - globalThis.queue = await resolveQueue( - globalThis.openNextConfig.middleware?.override?.queue, - ); + globalThis.queue = await resolveQueue(config?.override?.queue); globalThis.incrementalCache = await resolveIncrementalCache( - globalThis.openNextConfig.middleware?.override?.incrementalCache, + config?.override?.incrementalCache, ); //#endOverride @@ -61,7 +59,7 @@ const defaultHandler = async ( requestId, }, async () => { - const result = await routingHandler(internalEvent); + const result = await routingHandler(internalEvent, { assetResolver }); if ("internalEvent" in result) { debug("Middleware intercepted event", internalEvent); if (!result.isExternalRewrite) { diff --git a/packages/open-next/src/core/createMainHandler.ts b/packages/open-next/src/core/createMainHandler.ts index 8fdc618f4..ea3520c4a 100644 --- a/packages/open-next/src/core/createMainHandler.ts +++ b/packages/open-next/src/core/createMainHandler.ts @@ -4,6 +4,7 @@ import { debug } from "../adapters/logger"; import { generateUniqueId } from "../adapters/util"; import { openNextHandler } from "./requestHandler"; import { + resolveAssetResolver, resolveCdnInvalidation, resolveConverter, resolveIncrementalCache, @@ -38,6 +39,12 @@ export async function createMainHandler() { globalThis.tagCache = await resolveTagCache(thisFunction.override?.tagCache); + if (config.middleware?.external !== true) { + globalThis.assetResolver = await resolveAssetResolver( + globalThis.openNextConfig.middleware?.assetResolver, + ); + } + globalThis.proxyExternalRequest = await resolveProxyRequest( thisFunction.override?.proxyExternalRequest, ); diff --git a/packages/open-next/src/core/requestHandler.ts b/packages/open-next/src/core/requestHandler.ts index ba4060a9e..92b8a934f 100644 --- a/packages/open-next/src/core/requestHandler.ts +++ b/packages/open-next/src/core/requestHandler.ts @@ -80,7 +80,9 @@ export async function openNextHandler( }; //#override withRouting - routingResult = await routingHandler(internalEvent); + routingResult = await routingHandler(internalEvent, { + assetResolver: globalThis.assetResolver, + }); //#endOverride const headers = diff --git a/packages/open-next/src/core/resolve.ts b/packages/open-next/src/core/resolve.ts index 1c94328de..f8ca6dacb 100644 --- a/packages/open-next/src/core/resolve.ts +++ b/packages/open-next/src/core/resolve.ts @@ -116,6 +116,20 @@ export async function resolveOriginResolver( return m_1.default; } +/** + * @returns + * @__PURE__ + */ +export async function resolveAssetResolver( + assetResolver: RemoveUndefined["assetResolver"], +) { + if (typeof assetResolver === "function") { + return assetResolver(); + } + const m_1 = await import("../overrides/assetResolver/dummy.js"); + return m_1.default; +} + /** * @__PURE__ */ diff --git a/packages/open-next/src/core/routing/matcher.ts b/packages/open-next/src/core/routing/matcher.ts index 48c005944..89d7de7d7 100644 --- a/packages/open-next/src/core/routing/matcher.ts +++ b/packages/open-next/src/core/routing/matcher.ts @@ -170,6 +170,11 @@ export function getNextConfigHeaders( return requestHeaders; } +/** + * TODO: This method currently only check for the first match. + * It should check for all matches for `beforeFiles` and `afterFiles` rewrite + * See https://nextjs.org/docs/app/api-reference/config/next-config-js/rewrites + */ export function handleRewrites( event: InternalEvent, rewrites: T[], diff --git a/packages/open-next/src/core/routingHandler.ts b/packages/open-next/src/core/routingHandler.ts index 5b5665c94..95502d667 100644 --- a/packages/open-next/src/core/routingHandler.ts +++ b/packages/open-next/src/core/routingHandler.ts @@ -12,6 +12,7 @@ import type { RoutingResult, } from "types/open-next"; +import type { AssetResolver } from "types/overrides"; import { debug, error } from "../adapters/logger"; import { cacheInterceptor } from "./routing/cacheInterceptor"; import { detectLocale } from "./routing/i18n"; @@ -47,23 +48,31 @@ const geoHeaderToNextHeader = { "x-open-next-longitude": "x-vercel-ip-longitude", }; +/** + * Adds the middleware headers to an event or result. + * + * @param eventOrResult + * @param middlewareHeaders + */ function applyMiddlewareHeaders( - eventHeaders: Record, + eventOrResult: InternalEvent | InternalResult, middlewareHeaders: Record, - setPrefix = true, ) { - const keyPrefix = setPrefix ? MIDDLEWARE_HEADER_PREFIX : ""; + // Use the `MIDDLEWARE_HEADER_PREFIX` prefix for events, they will be processed by the request handler later. + // Results do not go through the request handler and should not be prefixed. + const isResult = isInternalResult(eventOrResult); + const headers = eventOrResult.headers; + const keyPrefix = isResult ? "" : MIDDLEWARE_HEADER_PREFIX; Object.entries(middlewareHeaders).forEach(([key, value]) => { if (value) { - eventHeaders[keyPrefix + key] = Array.isArray(value) - ? value.join(",") - : value; + headers[keyPrefix + key] = Array.isArray(value) ? value.join(",") : value; } }); } export default async function routingHandler( event: InternalEvent, + { assetResolver }: { assetResolver?: AssetResolver }, ): Promise { try { // Add Next geo headers @@ -87,14 +96,17 @@ export default async function routingHandler( } } - const nextHeaders = getNextConfigHeaders(event, ConfigHeaders); + // Headers from the Next config and middleware (the later are applied further down). + let headers: Record = + getNextConfigHeaders(event, ConfigHeaders); - let internalEvent = fixDataPage(event, BuildId); - if ("statusCode" in internalEvent) { - return internalEvent; + let eventOrResult = fixDataPage(event, BuildId); + + if (isInternalResult(eventOrResult)) { + return eventOrResult; } - const redirect = handleRedirects(internalEvent, RoutesManifest.redirects); + const redirect = handleRedirects(eventOrResult, RoutesManifest.redirects); if (redirect) { // We need to encode the value in the Location header to make sure it is valid according to RFC // https://stackoverflow.com/a/7654605/16587222 @@ -105,42 +117,56 @@ export default async function routingHandler( return redirect; } - const eventOrResult = await handleMiddleware( - internalEvent, + const middlewareEventOrResult = await handleMiddleware( + eventOrResult, // We need to pass the initial search without any decoding // TODO: we'd need to refactor InternalEvent to include the initial querystring directly // Should be done in another PR because it is a breaking change new URL(event.url).search, ); - const isResult = "statusCode" in eventOrResult; - if (isResult) { - return eventOrResult; + if (isInternalResult(middlewareEventOrResult)) { + return middlewareEventOrResult; } - const middlewareResponseHeaders = eventOrResult.responseHeaders; - let isExternalRewrite = eventOrResult.isExternalRewrite ?? false; - // internalEvent is `InternalEvent | MiddlewareEvent` - internalEvent = eventOrResult; + + headers = { + ...middlewareEventOrResult.responseHeaders, + ...headers, + }; + let isExternalRewrite = middlewareEventOrResult.isExternalRewrite ?? false; + eventOrResult = middlewareEventOrResult; if (!isExternalRewrite) { // First rewrite to be applied - const beforeRewrites = handleRewrites( - internalEvent, + const beforeRewrite = handleRewrites( + eventOrResult, RoutesManifest.rewrites.beforeFiles, ); - internalEvent = beforeRewrites.internalEvent; - isExternalRewrite = beforeRewrites.isExternalRewrite; + eventOrResult = beforeRewrite.internalEvent; + isExternalRewrite = beforeRewrite.isExternalRewrite; + // Check for matching public files after `beforeFiles` rewrites + // See: + // - https://nextjs.org/docs/app/api-reference/file-conventions/middleware#execution-order + // - https://nextjs.org/docs/app/api-reference/config/next-config-js/rewrites + if (!isExternalRewrite) { + const assetResult = + await assetResolver?.maybeGetAssetResult?.(eventOrResult); + if (assetResult) { + applyMiddlewareHeaders(assetResult, headers); + return assetResult; + } + } } - const foundStaticRoute = staticRouteMatcher(internalEvent.rawPath); + const foundStaticRoute = staticRouteMatcher(eventOrResult.rawPath); const isStaticRoute = !isExternalRewrite && foundStaticRoute.length > 0; if (!(isStaticRoute || isExternalRewrite)) { // Second rewrite to be applied - const afterRewrites = handleRewrites( - internalEvent, + const afterRewrite = handleRewrites( + eventOrResult, RoutesManifest.rewrites.afterFiles, ); - internalEvent = afterRewrites.internalEvent; - isExternalRewrite = afterRewrites.isExternalRewrite; + eventOrResult = afterRewrite.internalEvent; + isExternalRewrite = afterRewrite.isExternalRewrite; } let isISR = false; @@ -148,27 +174,27 @@ export default async function routingHandler( // We can skip it if its an external rewrite if (!isExternalRewrite) { const fallbackResult = handleFallbackFalse( - internalEvent, + eventOrResult, PrerenderManifest, ); - internalEvent = fallbackResult.event; + eventOrResult = fallbackResult.event; isISR = fallbackResult.isISR; } - const foundDynamicRoute = dynamicRouteMatcher(internalEvent.rawPath); + const foundDynamicRoute = dynamicRouteMatcher(eventOrResult.rawPath); const isDynamicRoute = !isExternalRewrite && foundDynamicRoute.length > 0; if (!(isDynamicRoute || isStaticRoute || isExternalRewrite)) { // Fallback rewrite to be applied const fallbackRewrites = handleRewrites( - internalEvent, + eventOrResult, RoutesManifest.rewrites.fallback, ); - internalEvent = fallbackRewrites.internalEvent; + eventOrResult = fallbackRewrites.internalEvent; isExternalRewrite = fallbackRewrites.isExternalRewrite; } - const isNextImageRoute = internalEvent.rawPath.startsWith("/_next/image"); + const isNextImageRoute = eventOrResult.rawPath.startsWith("/_next/image"); const isRouteFoundBeforeAllRewrites = isStaticRoute || isDynamicRoute || isExternalRewrite; @@ -180,16 +206,16 @@ export default async function routingHandler( isRouteFoundBeforeAllRewrites || isNextImageRoute || // We need to check again once all rewrites have been applied - staticRouteMatcher(internalEvent.rawPath).length > 0 || - dynamicRouteMatcher(internalEvent.rawPath).length > 0 + staticRouteMatcher(eventOrResult.rawPath).length > 0 || + dynamicRouteMatcher(eventOrResult.rawPath).length > 0 ) ) { - internalEvent = { - ...internalEvent, + eventOrResult = { + ...eventOrResult, rawPath: "/404", - url: constructNextUrl(internalEvent.url, "/404"), + url: constructNextUrl(eventOrResult.url, "/404"), headers: { - ...internalEvent.headers, + ...eventOrResult.headers, "x-middleware-response-cache-control": "private, no-cache, no-store, max-age=0, must-revalidate", }, @@ -198,28 +224,18 @@ export default async function routingHandler( if ( globalThis.openNextConfig.dangerous?.enableCacheInterception && - !("statusCode" in internalEvent) + !isInternalResult(eventOrResult) ) { debug("Cache interception enabled"); - internalEvent = await cacheInterceptor(internalEvent); - if ("statusCode" in internalEvent) { - applyMiddlewareHeaders( - internalEvent.headers, - { - ...middlewareResponseHeaders, - ...nextHeaders, - }, - false, - ); - return internalEvent; + eventOrResult = await cacheInterceptor(eventOrResult); + if (isInternalResult(eventOrResult)) { + applyMiddlewareHeaders(eventOrResult, headers); + return eventOrResult; } } // We apply the headers from the middleware response last - applyMiddlewareHeaders(internalEvent.headers, { - ...middlewareResponseHeaders, - ...nextHeaders, - }); + applyMiddlewareHeaders(eventOrResult, headers); const resolvedRoutes: ResolvedRoute[] = [ ...foundStaticRoute, @@ -229,14 +245,14 @@ export default async function routingHandler( debug("resolvedRoutes", resolvedRoutes); return { - internalEvent, + internalEvent: eventOrResult, isExternalRewrite, origin: false, isISR, resolvedRoutes, initialURL: event.url, locale: NextConfig.i18n - ? detectLocale(internalEvent, NextConfig.i18n) + ? detectLocale(eventOrResult, NextConfig.i18n) : undefined, }; } catch (e) { @@ -266,3 +282,13 @@ export default async function routingHandler( }; } } + +/** + * @param eventOrResult + * @returns Whether the event is an instance of `InternalResult` + */ +function isInternalResult( + eventOrResult: InternalEvent | InternalResult, +): eventOrResult is InternalResult { + return eventOrResult != null && "statusCode" in eventOrResult; +} diff --git a/packages/open-next/src/overrides/assetResolver/dummy.ts b/packages/open-next/src/overrides/assetResolver/dummy.ts new file mode 100644 index 000000000..5157d08c7 --- /dev/null +++ b/packages/open-next/src/overrides/assetResolver/dummy.ts @@ -0,0 +1,12 @@ +import type { AssetResolver } from "types/overrides"; + +/** + * A dummy asset resolver. + * + * It never overrides the result with an asset. + */ +const resolver: AssetResolver = { + name: "dummy", +}; + +export default resolver; diff --git a/packages/open-next/src/types/global.ts b/packages/open-next/src/types/global.ts index 882e57908..3f3bc1e62 100644 --- a/packages/open-next/src/types/global.ts +++ b/packages/open-next/src/types/global.ts @@ -2,6 +2,7 @@ import type { AsyncLocalStorage } from "node:async_hooks"; import type { OutgoingHttpHeaders } from "node:http"; import type { + AssetResolver, CDNInvalidationHandler, IncrementalCache, ProxyExternalRequest, @@ -214,6 +215,13 @@ declare global { */ var cdnInvalidationHandler: CDNInvalidationHandler; + /** + * The function called to resolve assets. + * Available in main functions + * Defined in `createMainHandler` when the middleware is internal + */ + var assetResolver: AssetResolver | undefined; + /** * A function to preload the routes. * This needs to be defined on globalThis because it can be used by custom overrides. diff --git a/packages/open-next/src/types/open-next.ts b/packages/open-next/src/types/open-next.ts index 17ab7ca09..8de290285 100644 --- a/packages/open-next/src/types/open-next.ts +++ b/packages/open-next/src/types/open-next.ts @@ -3,6 +3,7 @@ import type { ReadableStream } from "node:stream/web"; import type { Writable } from "node:stream"; import type { WarmerEvent, WarmerResponse } from "../adapters/warmer-function"; import type { + AssetResolver, CDNInvalidationHandler, Converter, ImageLoader, @@ -186,6 +187,9 @@ export type IncludedWarmer = "aws-lambda" | "dummy"; export type IncludedProxyExternalRequest = "node" | "fetch" | "dummy"; export type IncludedCDNInvalidationHandler = "cloudfront" | "dummy"; + +export type IncludedAssetResolver = "dummy"; + export interface DefaultOverrideOptions< E extends BaseEventOrResult = InternalEvent, R extends BaseEventOrResult = InternalResult, @@ -396,6 +400,13 @@ export interface OpenNextConfig { originResolver?: | IncludedOriginResolver | LazyLoadedOverride; + + /** + * The assetResolver is used to resolve assets in the routing layer. + * + * @default "dummy" + */ + assetResolver?: IncludedAssetResolver | LazyLoadedOverride; }; /** diff --git a/packages/open-next/src/types/overrides.ts b/packages/open-next/src/types/overrides.ts index 94e16c5cd..e6ea87120 100644 --- a/packages/open-next/src/types/overrides.ts +++ b/packages/open-next/src/types/overrides.ts @@ -31,6 +31,23 @@ export interface Queue { name: string; } +/** + * Resolves assets in the routing layer. + */ +export interface AssetResolver { + name: string; + + /** + * Called by the routing layer to check for a matching static asset. + * + * @param event + * @returns an `InternalResult` when an asset is found a the path from the event, undefined otherwise. + */ + maybeGetAssetResult?: ( + event: InternalEvent, + ) => Promise | undefined; +} + // Incremental cache export type CachedFile =