diff --git a/src/headers.test.ts b/src/headers.test.ts index a5f9514..9a7c4e7 100644 --- a/src/headers.test.ts +++ b/src/headers.test.ts @@ -34,11 +34,24 @@ describe('getHeaders', () => { }); it('should require the request context if next/headers is not available', () => { + const error = new Error('next/headers requires app router'); + mockHeaders.mockImplementation(() => { - throw new Error('next/headers requires app router'); + throw error; }); - expect(() => getHeaders()).toThrow('No route context found.'); + let actualError: unknown; + + try { + getHeaders(); + } catch (caughtError) { + actualError = caughtError; + } + + // Ensure it rethrows the exact same error. + // It is important because Next uses the error type to detect dynamic routes based + // on usage of headers or cookies + expect(actualError).toBe(error); }); type RequestContextScenario = { @@ -138,11 +151,24 @@ describe('getCookies', () => { }); it('should require the request context if next/headers is not available', () => { + const error = new Error('next/headers requires app router'); + mockCookies.mockImplementation(() => { - throw new Error('next/headers requires app router'); + throw error; }); - expect(() => getCookies()).toThrow('No route context found.'); + let actualError: unknown; + + try { + getCookies(); + } catch (caughtError) { + actualError = caughtError; + } + + // Ensure it rethrows the exact same error. + // It is important because Next uses the error type to detect dynamic routes based + // on usage of headers or cookies + expect(actualError).toBe(error); }); type RequestContextScenario = { diff --git a/src/headers.ts b/src/headers.ts index 97fe433..b78baa2 100644 --- a/src/headers.ts +++ b/src/headers.ts @@ -36,9 +36,9 @@ export function getHeaders(route?: RouteContext): HeaderReader { const {headers} = importNextHeaders(); return headers(); - } catch { + } catch (error) { if (route === undefined) { - throw new Error('No route context found.'); + throw error; } } @@ -70,9 +70,9 @@ export function getCookies(route?: RouteContext): CookieAccessor { const {cookies} = importNextHeaders(); return cookies(); - } catch { + } catch (error) { if (route === undefined) { - throw new Error('No route context found.'); + throw error; } } diff --git a/src/server/evaluate.test.ts b/src/server/evaluate.test.ts index 747894b..91a7716 100644 --- a/src/server/evaluate.test.ts +++ b/src/server/evaluate.test.ts @@ -4,6 +4,7 @@ import {ApiKey, ApiKey as MockApiKey} from '@croct/sdk/apiKey'; import {FilteredLogger} from '@croct/sdk/logging/filteredLogger'; import {headers} from 'next/headers'; import {NextRequest, NextResponse} from 'next/server'; +import {DynamicServerError} from 'next/dist/client/components/hooks-server-context'; import {cql, evaluate, EvaluationOptions} from './evaluate'; import {resolveRequestContext, RequestContext} from '@/config/context'; import {getDefaultFetchTimeout} from '@/config/timeout'; @@ -186,6 +187,16 @@ describe('evaluation', () => { expect(resolveRequestContext).toHaveBeenCalledWith(route); }); + it('should rethrow dynamic server errors', async () => { + const error = new DynamicServerError('cause'); + + jest.mocked(resolveRequestContext).mockImplementation(() => { + throw error; + }); + + await expect(evaluate('true')).rejects.toBe(error); + }); + it('should report an error if the route context is missing', async () => { jest.mocked(resolveRequestContext).mockImplementation(() => { throw new Error('next/headers requires app router'); @@ -193,8 +204,8 @@ describe('evaluation', () => { await expect(evaluate('true')).rejects.toThrow( 'Error resolving request context: next/headers requires app router. ' - + 'This error usually occurs when no `route` option is specified when evaluate() ' - + 'is called outside of app routes. ' + + 'This error typically occurs when evaluate() is called outside of app routes ' + + 'without specifying the `route` option. ' + 'For help, see: https://croct.help/sdk/nextjs/missing-route-context', ); }); diff --git a/src/server/evaluate.ts b/src/server/evaluate.ts index 8a8dacd..16cc8f6 100644 --- a/src/server/evaluate.ts +++ b/src/server/evaluate.ts @@ -3,6 +3,7 @@ import type {JsonValue} from '@croct/plug-react'; import {FilteredLogger} from '@croct/sdk/logging/filteredLogger'; import {ConsoleLogger} from '@croct/sdk/logging/consoleLogger'; import {formatCause} from '@croct/sdk/error'; +import {isDynamicServerError} from 'next/dist/client/components/hooks-server-context'; import {getApiKey} from '@/config/security'; import {RequestContext, resolveRequestContext} from '@/config/context'; import {getDefaultFetchTimeout} from '@/config/timeout'; @@ -21,18 +22,18 @@ export function evaluate(query: string, options: Evaluation try { context = resolveRequestContext(route); } catch (error) { - if (route === undefined) { - return Promise.reject( - new Error( - `Error resolving request context: ${formatCause(error)}. ` - + 'This error usually occurs when no `route` option is specified when evaluate() ' - + 'is called outside of app routes. ' - + 'For help, see: https://croct.help/sdk/nextjs/missing-route-context', - ), - ); + if (isDynamicServerError(error) || route !== undefined) { + return Promise.reject(error); } - return Promise.reject(error); + return Promise.reject( + new Error( + `Error resolving request context: ${formatCause(error)}. ` + + 'This error typically occurs when evaluate() is called outside of app routes ' + + 'without specifying the `route` option. ' + + 'For help, see: https://croct.help/sdk/nextjs/missing-route-context', + ), + ); } const timeout = getDefaultFetchTimeout(); diff --git a/src/server/fetchContent.test.ts b/src/server/fetchContent.test.ts index 5162449..63bd2c6 100644 --- a/src/server/fetchContent.test.ts +++ b/src/server/fetchContent.test.ts @@ -4,6 +4,7 @@ import {FetchResponse} from '@croct/plug/plug'; import {ApiKey, ApiKey as MockApiKey} from '@croct/sdk/apiKey'; import {FilteredLogger} from '@croct/sdk/logging/filteredLogger'; import type {NextRequest, NextResponse} from 'next/server'; +import {DynamicServerError} from 'next/dist/client/components/hooks-server-context'; import {fetchContent, FetchOptions} from './fetchContent'; import {RequestContext, resolvePreferredLocale, resolveRequestContext} from '@/config/context'; import {getDefaultFetchTimeout} from '@/config/timeout'; @@ -289,6 +290,16 @@ describe('fetchContent', () => { expect(resolvePreferredLocale).toHaveBeenCalledWith(route); }); + it('should rethrow dynamic server errors', async () => { + const error = new DynamicServerError('cause'); + + jest.mocked(resolveRequestContext).mockImplementation(() => { + throw error; + }); + + await expect(fetchContent('slot-id')).rejects.toBe(error); + }); + it('should report an error if the route context is missing', async () => { jest.mocked(resolveRequestContext).mockImplementation(() => { throw new Error('next/headers requires app router'); @@ -296,8 +307,8 @@ describe('fetchContent', () => { await expect(fetchContent('slot-id')).rejects.toThrow( 'Error resolving request context: next/headers requires app router. ' - + 'This error usually occurs when no `route` option is specified when fetchContent() ' - + 'is called outside of app routes. ' + + 'This error typically occurs when fetchContent() is called outside of app routes without ' + + 'specifying the `route` option. ' + 'For help, see: https://croct.help/sdk/nextjs/missing-route-context', ); }); diff --git a/src/server/fetchContent.ts b/src/server/fetchContent.ts index 72c695a..9eda835 100644 --- a/src/server/fetchContent.ts +++ b/src/server/fetchContent.ts @@ -8,6 +8,7 @@ import type {SlotContent, VersionedSlotId, JsonObject} from '@croct/plug-react'; import {FilteredLogger} from '@croct/sdk/logging/filteredLogger'; import {ConsoleLogger} from '@croct/sdk/logging/consoleLogger'; import {formatCause} from '@croct/sdk/error'; +import {isDynamicServerError} from 'next/dist/client/components/hooks-server-context'; import {getApiKey} from '@/config/security'; import {RequestContext, resolvePreferredLocale, resolveRequestContext} from '@/config/context'; import {getDefaultFetchTimeout} from '@/config/timeout'; @@ -63,18 +64,18 @@ export function fetchContent( try { context = resolveRequestContext(route); } catch (error) { - if (route === undefined) { - return Promise.reject( - new Error( - `Error resolving request context: ${formatCause(error)}. ` - + 'This error usually occurs when no `route` option is specified when fetchContent() ' - + 'is called outside of app routes. ' - + 'For help, see: https://croct.help/sdk/nextjs/missing-route-context', - ), - ); + if (isDynamicServerError(error) || route !== undefined) { + return Promise.reject(error); } - return Promise.reject(error); + return Promise.reject( + new Error( + `Error resolving request context: ${formatCause(error)}. ` + + 'This error typically occurs when fetchContent() is called outside of app routes ' + + 'without specifying the `route` option. ' + + 'For help, see: https://croct.help/sdk/nextjs/missing-route-context', + ), + ); } return loadContent(slotId, {