From 204e10b7ce8ab8ba455c63bf8720a566c37c874d Mon Sep 17 00:00:00 2001 From: Jonathan Haines Date: Sat, 29 Jun 2024 18:35:20 +1000 Subject: [PATCH] feat(cloudflare-pages): Add Cloudflare Pages middleware handler (#3028) * Add Cloudflare Pages middleware handler * fix: handle HTTPException * fix: handle context.error * fix: remove HonoRequest from conninfo test --- src/adapter/cloudflare-pages/handler.test.ts | 194 ++++++++++++++++++- src/adapter/cloudflare-pages/handler.ts | 59 +++++- src/adapter/cloudflare-pages/index.ts | 2 +- 3 files changed, 252 insertions(+), 3 deletions(-) diff --git a/src/adapter/cloudflare-pages/handler.test.ts b/src/adapter/cloudflare-pages/handler.test.ts index c6e1443a..8f57b7c8 100644 --- a/src/adapter/cloudflare-pages/handler.test.ts +++ b/src/adapter/cloudflare-pages/handler.test.ts @@ -1,6 +1,8 @@ +import { getCookie } from '../../helper/cookie' import { Hono } from '../../hono' +import { HTTPException } from '../../http-exception' import type { EventContext } from './handler' -import { handle } from './handler' +import { handle, handleMiddleware } from './handler' type Env = { Bindings: { @@ -49,3 +51,193 @@ describe('Adapter for Cloudflare Pages', () => { expect(() => handler({ request })).toThrowError('Custom Error') }) }) + +describe('Middleware adapter for Cloudflare Pages', () => { + it('Should return the middleware response', async () => { + const request = new Request('http://localhost/api/foo', { + headers: { + Cookie: 'my_cookie=1234', + }, + }) + const env = { + TOKEN: 'HONOISCOOL', + } + const handler = handleMiddleware(async (c, next) => { + const cookie = getCookie(c, 'my_cookie') + + await next() + + return c.json({ cookie, response: await c.res.json() }) + }) + + const next = vi.fn().mockResolvedValue(Response.json('From Cloudflare Pages')) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const res = await handler({ request, env, next }) + + expect(next).toHaveBeenCalled() + + expect(await res.json()).toEqual({ + cookie: '1234', + response: 'From Cloudflare Pages', + }) + }) + + it('Should return the middleware response when exceptions are handled', async () => { + const request = new Request('http://localhost/api/foo') + const env = { + TOKEN: 'HONOISCOOL', + } + const handler = handleMiddleware(async (c, next) => { + await next() + + return c.json({ error: c.error?.message }) + }) + + const next = vi.fn().mockRejectedValue(new Error('Error from next()')) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const res = await handler({ request, env, next }) + + expect(next).toHaveBeenCalled() + + expect(await res.json()).toEqual({ + error: 'Error from next()', + }) + }) + + it('Should return the middleware response if next() is not called', async () => { + const request = new Request('http://localhost/api/foo') + const env = { + TOKEN: 'HONOISCOOL', + } + const handler = handleMiddleware(async (c) => { + return c.json({ response: 'Skip Cloudflare Pages' }) + }) + + const next = vi.fn() + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const res = await handler({ request, env, next }) + + expect(next).not.toHaveBeenCalled() + + expect(await res.json()).toEqual({ + response: 'Skip Cloudflare Pages', + }) + }) + + it('Should return the Pages response if the middleware does not return a response', async () => { + const request = new Request('http://localhost/api/foo') + const env = { + TOKEN: 'HONOISCOOL', + } + const handler = handleMiddleware((c, next) => next()) + + const next = vi.fn().mockResolvedValue(Response.json('From Cloudflare Pages')) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const res = await handler({ request, env, next }) + + expect(next).toHaveBeenCalled() + + expect(await res.json()).toEqual('From Cloudflare Pages') + }) + + it('Should handle a HTTPException by returning error.getResponse()', async () => { + const request = new Request('http://localhost/api/foo') + const env = { + TOKEN: 'HONOISCOOL', + } + const handler = handleMiddleware(() => { + const res = new Response('Unauthorized', { status: 401 }) + throw new HTTPException(401, { res }) + }) + + const next = vi.fn() + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const res = await handler({ request, env, next }) + + expect(next).not.toHaveBeenCalled() + + expect(res.status).toBe(401) + expect(await res.text()).toBe('Unauthorized') + }) + + it('Should handle an HTTPException thrown by next()', async () => { + const request = new Request('http://localhost/api/foo') + const env = { + TOKEN: 'HONOISCOOL', + } + const handler = handleMiddleware((c, next) => next()) + + const next = vi + .fn() + .mockRejectedValue(new HTTPException(401, { res: Response.json('Unauthorized') })) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const res = await handler({ request, env, next }) + + expect(next).toHaveBeenCalled() + + expect(await res.json()).toEqual('Unauthorized') + }) + + it('Should handle an Error thrown by next()', async () => { + const request = new Request('http://localhost/api/foo') + const env = { + TOKEN: 'HONOISCOOL', + } + const handler = handleMiddleware((c, next) => next()) + + const next = vi.fn().mockRejectedValue(new Error('Error from next()')) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await expect(handler({ request, env, next })).rejects.toThrowError('Error from next()') + expect(next).toHaveBeenCalled() + }) + + it('Should handle a non-Error thrown by next()', async () => { + const request = new Request('http://localhost/api/foo') + const env = { + TOKEN: 'HONOISCOOL', + } + const handler = handleMiddleware((c, next) => next()) + + const next = vi.fn().mockRejectedValue('Error from next()') + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await expect(handler({ request, env, next })).rejects.toThrowError('Error from next()') + expect(next).toHaveBeenCalled() + }) + + it('Should rethrow an Error', async () => { + const request = new Request('http://localhost/api/foo') + const env = { + TOKEN: 'HONOISCOOL', + } + const handler = handleMiddleware(() => { + throw new Error('Something went wrong') + }) + + const next = vi.fn() + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await expect(handler({ request, env, next })).rejects.toThrowError('Something went wrong') + expect(next).not.toHaveBeenCalled() + }) + + it('Should rethrow non-Error exceptions', async () => { + const request = new Request('http://localhost/api/foo') + const env = { + TOKEN: 'HONOISCOOL', + } + const handler = handleMiddleware(() => Promise.reject('Something went wrong')) + const next = vi.fn() + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await expect(handler({ request, env, next })).rejects.toThrowError('Something went wrong') + expect(next).not.toHaveBeenCalled() + }) +}) diff --git a/src/adapter/cloudflare-pages/handler.ts b/src/adapter/cloudflare-pages/handler.ts index 7e3f25e1..371175b8 100644 --- a/src/adapter/cloudflare-pages/handler.ts +++ b/src/adapter/cloudflare-pages/handler.ts @@ -1,5 +1,7 @@ +import { Context } from '../../context' import type { Hono } from '../../hono' -import type { MiddlewareHandler } from '../../types' +import { HTTPException } from '../../http-exception' +import type { Env, Input, MiddlewareHandler } from '../../types' // Ref: https://github.com/cloudflare/workerd/blob/main/types/defines/pages.d.ts @@ -18,6 +20,13 @@ export type EventContext = { data: Data } +declare type PagesFunction< + Env = unknown, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Params extends string = any, + Data extends Record = Record +> = (context: EventContext) => Response | Promise + interface HandleInterface { // eslint-disable-next-line @typescript-eslint/no-explicit-any (app: Hono): (eventContext: EventContext) => Response | Promise @@ -34,6 +43,54 @@ export const handle: HandleInterface = (app: Hono) => (eventContext: EventContex ) } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function handleMiddleware( + middleware: MiddlewareHandler +): PagesFunction { + return async (executionCtx) => { + const context = new Context(executionCtx.request, { + env: executionCtx.env, + executionCtx, + }) + + let response: Response | void = undefined + + try { + response = await middleware(context, async () => { + try { + context.res = await executionCtx.next() + } catch (error) { + if (error instanceof Error) { + context.error = error + } else { + throw error + } + } + }) + } catch (error) { + if (error instanceof Error) { + context.error = error + } else { + throw error + } + } + + if (response) { + return response + } + + if (context.error instanceof HTTPException) { + return context.error.getResponse() + } + + if (context.error) { + throw context.error + } + + return context.res + } +} + declare abstract class FetcherLike { fetch(input: RequestInfo, init?: RequestInit): Promise } diff --git a/src/adapter/cloudflare-pages/index.ts b/src/adapter/cloudflare-pages/index.ts index e5362c81..0bbeb2a3 100644 --- a/src/adapter/cloudflare-pages/index.ts +++ b/src/adapter/cloudflare-pages/index.ts @@ -3,5 +3,5 @@ * Cloudflare Pages Adapter for Hono. */ -export { handle, serveStatic } from './handler' +export { handle, handleMiddleware, serveStatic } from './handler' export type { EventContext } from './handler'