From 0e91134bcd072436786e21263abf0fc4204f3284 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 <52163001+3m1n3nc3@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:15:08 +0000 Subject: [PATCH 1/2] feat: support Fastify replies in ServerResponse and resources --- docs/guide/non-connect-frameworks.md | 28 ++++++- docs/guide/server-response.md | 5 +- docs/more/changelog.md | 7 +- docs/more/roadmap.md | 14 +++- package.json | 1 + pnpm-lock.yaml | 3 + src/GenericResource.ts | 16 ++-- src/Resource.ts | 16 ++-- src/ResourceCollection.ts | 16 ++-- src/ServerResponse.ts | 9 +- src/types/common.ts | 14 ++++ src/utilities/state.ts | 5 ++ tests/fastify.spec.ts | 120 +++++++++++++++++++++++++++ 13 files changed, 221 insertions(+), 33 deletions(-) create mode 100644 tests/fastify.spec.ts diff --git a/docs/guide/non-connect-frameworks.md b/docs/guide/non-connect-frameworks.md index 4061c9e..7a791aa 100644 --- a/docs/guide/non-connect-frameworks.md +++ b/docs/guide/non-connect-frameworks.md @@ -1,4 +1,4 @@ -# Using Resora Outside H3/Express (Non-Connect Frameworks) +# Using Resora Outside H3/Express/Fastify (Non-Connect Frameworks) Resora works as a pure transformation layer, even when your framework does **not** expose a Connect-style response object. @@ -51,7 +51,11 @@ Resora separates: When Connect-style response objects are unavailable, keep transport in your framework and use Resora for transformation only. -## Fastify Example (Current Recommended Pattern) +## Fastify + +Resora dispatches through a Fastify `reply` directly. Pass the reply to the +resource (or to `.response()`), then return the reply so Fastify knows the +response has been sent: ```ts import { Resource } from 'resora'; @@ -59,9 +63,25 @@ import { Resource } from 'resora'; fastify.get('/users/:id', async (request, reply) => { const user = await findUser(request.params.id); - const body = new Resource(user).withMeta({ traceId: request.id }).getBody(); + await new Resource(user, reply) + .withMeta({ traceId: request.id }) + .response() + .setStatusCode(200) + .header('X-Resource', 'user'); + + return reply; +}); +``` - return reply.code(200).send(body); +A `{ req, reply }` context, as clear-router's Fastify adapter provides, works +the same way. With clear-router, the `clearRouterFastifyPlugin` from +`@resora/plugin-clear-router` returns the reply for you. + +To keep Fastify in charge of sending, return the body instead: + +```ts +fastify.get('/users/:id', async (request) => { + return new Resource(await findUser(request.params.id)).getBody(); }); ``` diff --git a/docs/guide/server-response.md b/docs/guide/server-response.md index 12db4de..f3242fb 100644 --- a/docs/guide/server-response.md +++ b/docs/guide/server-response.md @@ -13,7 +13,7 @@ Binds the internal Resource instance to a framework-specific response object. ### Parameters - `res` - The framework’s native response object (e.g., H3 `res`, Express `res`) or a full context object containing `{ req, res }`. + The framework’s native response object (e.g., H3 `res`, Express `res`, Fastify `reply`) or a full context object containing `{ req, res }`. > For Express and other frameworks implementing the connect-style middleware, this can be skipped, in which case Resora will use the response object provided in the `Resource` or `ResourceCollection` constructor. > @@ -174,12 +174,13 @@ Transport state and payload state are intentionally separated. - H3 (native Fetch-style) - Express +- Fastify (`reply`, or a `{ req, reply }` context) - Connect-style middleware frameworks - Any framework exposing a mutable response object Resora does not replace the response lifecycle — it integrates into it. -If you're using a framework outside H3/Express or non-Connect response styles, use the transformation-first pattern documented in [Using Resora Outside H3/Express (Non-Connect Frameworks)](./non-connect-frameworks.md). +If you're using a framework outside H3/Express/Fastify or non-Connect response styles, use the transformation-first pattern documented in [Using Resora Outside H3/Express/Fastify (Non-Connect Frameworks)](./non-connect-frameworks.md). ## If `.response()` Is Not Used diff --git a/docs/more/changelog.md b/docs/more/changelog.md index cbb53af..43bce3d 100644 --- a/docs/more/changelog.md +++ b/docs/more/changelog.md @@ -16,6 +16,11 @@ The format follows semantic versioning principles. - Added direct support for Arkormˣ paginated results (`LengthAwarePaginator` and `Paginator`) across `ResourceCollection` and `GenericResource` flows. - Added direct support for Arkormˣ Models and Collections across `ResourceCollection` and `GenericResource` flows. +- Added direct support for Fastify replies in `withResponse()`, `.response()`, and `ServerResponse`, including clear-router's `{ req, reply }` context. + +### Fixed + +- `clearRouterFastifyPlugin` returns the sent reply to Fastify, so Fastify no longer warns that the reply was already sent. ### Documentation @@ -27,7 +32,7 @@ The format follows semantic versioning principles. ### Planned - Plugin System for lifecycle extension without core modification -- Direct support for Next.Js API, Nest.JS, and Fastify response objects in `withResponse()`, `.response()`, and `ServerResponse` class. +- Direct support for Next.Js API and Nest.JS response objects in `withResponse()`, `.response()`, and `ServerResponse` class. - Built-in support for Arkormˣ models for seamless transformation of Arkormˣ entities. ## [0.2.1] - [0.2.2] - Patch Release Series diff --git a/docs/more/roadmap.md b/docs/more/roadmap.md index 070ebb3..2b2070f 100644 --- a/docs/more/roadmap.md +++ b/docs/more/roadmap.md @@ -84,9 +84,19 @@ Add conditional transformation helpers: ✅ Implemented -## NextJs API, NestJS and Fastify Support +## Fastify Support -Add direct support for NextJs API, NestJS, and Fastify response objects in: +Direct support for Fastify replies in: + +- `withResponse()` hooks +- `.response()` dispatch flow +- `ServerResponse` adapter handling + +✅ Implemented + +## NextJs API and NestJS Support + +Add direct support for NextJs API and NestJS response objects in: - `withResponse()` hooks - `.response()` dispatch flow diff --git a/package.json b/package.json index 566a10a..a2b5b6c 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "barrelize": "^1.7.3", "eslint": "^10.0.0", "express": "^5.1.0", + "fastify": "^5.8.5", "h3": "2.0.1-rc.14", "parasito": "^0.1.6", "tsdown": "^0.22.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ad6114..548e1a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: express: specifier: ^5.1.0 version: 5.2.1 + fastify: + specifier: ^5.8.5 + version: 5.8.5 h3: specifier: 2.0.1-rc.14 version: 2.0.1-rc.14 diff --git a/src/GenericResource.ts b/src/GenericResource.ts index 0a8738c..468a460 100644 --- a/src/GenericResource.ts +++ b/src/GenericResource.ts @@ -2,6 +2,7 @@ import type { H3Event } from 'h3' import { CollectionLike, Collectible, + FastifyReplyLike, GenericBody, MetaData, NonCollectible, @@ -52,7 +53,7 @@ export class GenericResource< public collects?: typeof Resource protected withResponseContext?: { response: ServerResponse> - raw: Response | H3Event['res'] + raw: Response | H3Event['res'] | FastifyReplyLike } constructor(rsc: R, ctx?: Response | H3Event | Record) { @@ -473,6 +474,7 @@ export class GenericResource< */ response(res: H3Event['res']): ServerResponse> response(res: Response): ServerResponse> + response(res: FastifyReplyLike): ServerResponse> /** * Build a response object, writing to the provided raw response if possible. @@ -480,8 +482,8 @@ export class GenericResource< * @param res * @returns */ - response(res?: Response | H3Event['res']): ServerResponse> { - const rawResponse = this.resolveRawResponse(res ?? this.res) as Response | H3Event['res'] + response(res?: Response | H3Event['res'] | FastifyReplyLike): ServerResponse> { + const rawResponse = this.resolveRawResponse(res ?? this.res) as Response | H3Event['res'] | FastifyReplyLike return this.runResponse({ ensureJson: () => this.json(), @@ -510,7 +512,7 @@ export class GenericResource< */ withResponse( _response?: ServerResponse>, - _rawResponse?: Response | H3Event['res'] + _rawResponse?: Response | H3Event['res'] | FastifyReplyLike ): any { return this } @@ -531,7 +533,7 @@ export class GenericResource< await this.jsonAsync() }, body: () => this.body, - rawResponse: this.resolveRawResponse(this.res) as Response | H3Event['res'], + rawResponse: this.resolveRawResponse(this.res) as Response | H3Event['res'] | FastifyReplyLike, createServerResponse: (raw, body) => { const response = new ServerResponse(raw as never, body) this.withResponseContext = { @@ -566,7 +568,7 @@ export class GenericResource< await this.jsonAsync() }, body: () => this.body, - rawResponse: this.resolveRawResponse(this.res) as Response | H3Event['res'], + rawResponse: this.resolveRawResponse(this.res) as Response | H3Event['res'] | FastifyReplyLike, createServerResponse: (raw, body) => { const response = new ServerResponse(raw as never, body) this.withResponseContext = { @@ -598,7 +600,7 @@ export class GenericResource< await this.jsonAsync() }, body: () => this.body, - rawResponse: this.resolveRawResponse(this.res) as Response | H3Event['res'], + rawResponse: this.resolveRawResponse(this.res) as Response | H3Event['res'] | FastifyReplyLike, createServerResponse: (raw, body) => { const response = new ServerResponse(raw as never, body) this.withResponseContext = { diff --git a/src/Resource.ts b/src/Resource.ts index 32f2821..b89e1a2 100644 --- a/src/Resource.ts +++ b/src/Resource.ts @@ -2,6 +2,7 @@ import type { H3Event } from 'h3' import { CollectionLike, Collectible, + FastifyReplyLike, MetaData, NonCollectible, PaginatorLike, @@ -44,7 +45,7 @@ export class Resource ex public resource: R protected withResponseContext?: { response: ServerResponse> - raw: Response | H3Event['res'] + raw: Response | H3Event['res'] | FastifyReplyLike } constructor(rsc: R, ctx?: Response | H3Event | Record) { @@ -368,14 +369,15 @@ export class Resource ex */ response(res: H3Event['res']): ServerResponse> response(res: Response): ServerResponse> + response(res: FastifyReplyLike): ServerResponse> /** * Build a response object, optionally accepting a raw response to mutate in withResponse. * * @param res Optional raw response object (e.g. Express Response or H3Event res) * @returns */ - response(res?: H3Event['res'] | Response): ServerResponse> { - const rawResponse = this.resolveRawResponse(res ?? this.res) as H3Event['res'] | Response + response(res?: H3Event['res'] | Response | FastifyReplyLike): ServerResponse> { + const rawResponse = this.resolveRawResponse(res ?? this.res) as H3Event['res'] | Response | FastifyReplyLike return this.runResponse({ ensureJson: () => this.json(), @@ -404,7 +406,7 @@ export class Resource ex */ withResponse( _response?: ServerResponse>, - _rawResponse?: Response | H3Event['res'] + _rawResponse?: Response | H3Event['res'] | FastifyReplyLike ): any { return this } @@ -425,7 +427,7 @@ export class Resource ex await this.jsonAsync() }, body: () => this.body, - rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response, + rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response | FastifyReplyLike, createServerResponse: (raw, body) => { const response = new ServerResponse(raw as never, body) this.withResponseContext = { @@ -460,7 +462,7 @@ export class Resource ex await this.jsonAsync() }, body: () => this.body, - rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response, + rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response | FastifyReplyLike, createServerResponse: (raw, body) => { const response = new ServerResponse(raw as never, body) this.withResponseContext = { @@ -492,7 +494,7 @@ export class Resource ex await this.jsonAsync() }, body: () => this.body, - rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response, + rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response | FastifyReplyLike, createServerResponse: (raw, body) => { const response = new ServerResponse(raw as never, body) this.withResponseContext = { diff --git a/src/ResourceCollection.ts b/src/ResourceCollection.ts index 2e90f73..b9e8bcb 100644 --- a/src/ResourceCollection.ts +++ b/src/ResourceCollection.ts @@ -1,6 +1,7 @@ import type { H3Event } from 'h3' import { CollectionLike, + FastifyReplyLike, ResourceData, Collectible, CollectionBody, @@ -51,7 +52,7 @@ export class ResourceCollection< public collects?: typeof Resource protected withResponseContext?: { response: ServerResponse> - raw: Response | H3Event['res'] + raw: Response | H3Event['res'] | FastifyReplyLike } /** @@ -521,14 +522,15 @@ export class ResourceCollection< */ response(res: H3Event['res']): ServerResponse> response(res: Response): ServerResponse> + response(res: FastifyReplyLike): ServerResponse> /** * Build a response object, optionally accepting a raw response to mutate in withResponse. * * @param res Optional raw response object (e.g. Express Response or H3Event res) * @returns */ - response(res?: H3Event['res'] | Response): ServerResponse> { - const rawResponse = this.resolveRawResponse(res ?? this.res) as H3Event['res'] | Response + response(res?: H3Event['res'] | Response | FastifyReplyLike): ServerResponse> { + const rawResponse = this.resolveRawResponse(res ?? this.res) as H3Event['res'] | Response | FastifyReplyLike return this.runResponse({ ensureJson: () => this.json(), @@ -557,7 +559,7 @@ export class ResourceCollection< */ withResponse( _response?: ServerResponse>, - _rawResponse?: Response | H3Event['res'] + _rawResponse?: Response | H3Event['res'] | FastifyReplyLike ): any { return this } @@ -584,7 +586,7 @@ export class ResourceCollection< await this.jsonAsync() }, body: () => this.body, - rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response, + rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response | FastifyReplyLike, createServerResponse: (raw, body) => { const response = new ServerResponse(raw as never, body) this.withResponseContext = { @@ -619,7 +621,7 @@ export class ResourceCollection< await this.jsonAsync() }, body: () => this.body, - rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response, + rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response | FastifyReplyLike, createServerResponse: (raw, body) => { const response = new ServerResponse(raw as never, body) this.withResponseContext = { @@ -651,7 +653,7 @@ export class ResourceCollection< await this.jsonAsync() }, body: () => this.body, - rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response, + rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response | FastifyReplyLike, createServerResponse: (raw, body) => { const response = new ServerResponse(raw as never, body) this.withResponseContext = { diff --git a/src/ServerResponse.ts b/src/ServerResponse.ts index 050c05f..7be3f43 100644 --- a/src/ServerResponse.ts +++ b/src/ServerResponse.ts @@ -1,4 +1,4 @@ -import { Collectible, NonCollectible, ResourceData } from './types' +import { Collectible, FastifyReplyLike, NonCollectible, ResourceData } from './types' import type { H3Event } from 'h3' import type { Response } from 'express' @@ -13,7 +13,7 @@ export interface ServerResponseData { /** * ServerResponse class to handle HTTP response construction and sending, compatible - * with both Express and H3 response objects. + * with Express, H3 and Fastify response objects. * * @author Legacy (3m1n3nc3) * @since 0.1.0 @@ -33,8 +33,9 @@ export class ServerResponse< constructor(response: H3Event['res'], body: R) constructor(response: Response, body: R) + constructor(response: FastifyReplyLike, body: R) constructor(response: undefined, body: R) - constructor(private response: Response | H3Event['res'] | undefined, private body: R) { } + constructor(private response: Response | H3Event['res'] | FastifyReplyLike | undefined, private body: R) { } /** * Set the HTTP status code for the response @@ -93,6 +94,8 @@ export class ServerResponse< return this.response.statusMessage } else if (this.response && 'statusText' in this.response) { return this.response.statusText + } else if (this.response && 'raw' in this.response) { + return this.response.raw.statusMessage } return undefined diff --git a/src/types/common.ts b/src/types/common.ts index b7b38d7..94b33e8 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -46,3 +46,17 @@ export interface ResponseStructureConfig { */ factory?: ResponseFactory | undefined } + +/** + * @description The parts of a Fastify `reply` that Resora uses to dispatch a response. + */ +export interface FastifyReplyLike { + code (statusCode: number): unknown + header (key: string, value: any): unknown + send (payload?: any): unknown + readonly sent: boolean + raw: { + statusMessage?: string + headersSent?: boolean + } +} diff --git a/src/utilities/state.ts b/src/utilities/state.ts index fc60fed..a5ad55f 100644 --- a/src/utilities/state.ts +++ b/src/utilities/state.ts @@ -384,6 +384,11 @@ export const extractResponseFromCtx = (ctx: unknown): any | undefined => { return obj.res } + // Fastify context ({ req, reply } from clear-router) + if (obj.reply && typeof obj.reply === 'object') { + return obj.reply + } + // Bare response object passed directly return ctx } diff --git a/tests/fastify.spec.ts b/tests/fastify.spec.ts new file mode 100644 index 0000000..95ea2b1 --- /dev/null +++ b/tests/fastify.spec.ts @@ -0,0 +1,120 @@ +import Fastify, { type FastifyInstance } from 'fastify' +import { Resource, ResourceCollection, ServerResponse } from 'src' +import { beforeEach, describe, expect, it } from 'vitest' + +let app: FastifyInstance + +describe('Fastify Requests', () => { + beforeEach(() => { + app = Fastify() + }) + + it('should output correct JSON response', async () => { + const resource = { id: 1, name: 'Test Resource' } + app.get('/test', async (_req, reply) => { + return await new Resource(resource, reply).json() + }) + + const response = await app.inject().get('/test') + + expect(response.json()).toEqual({ data: resource }) + }) + + it('should use the reply from a { req, res } context', async () => { + const resource = { id: 1, name: 'Test Resource' } + app.get('/test', async (req, reply) => { + Resource.setCtx({ req, res: reply }) + + return await new Resource(resource).response().setStatusCode(202) + }) + + const response = await app.inject().get('/test') + + expect(response.statusCode).toEqual(202) + expect(response.json()).toEqual({ data: resource }) + }) + + it('should use the reply from a clear-router { req, reply } context', async () => { + app.get('/test', async (req, reply) => { + return await new Resource({ id: 1 }, { req, reply }).response().setStatusCode(201) + }) + + const response = await app.inject().get('/test') + + expect(response.statusCode).toEqual(201) + expect(response.json()).toEqual({ data: { id: 1 } }) + }) + + it('should allow setting status code, headers and cookies', async () => { + const resource = { id: 1, name: 'Test Resource' } + app.get('/test', async (_req, reply) => { + return await new Resource(resource, reply) + .response() + .setStatusCode(201) + .header('X-Custom-Header', 'CustomValue') + .setCookie('testCookie', 'testValue', { path: '/' }) + }) + + const response = await app.inject().get('/test') + + expect(response.statusCode).toEqual(201) + expect(response.headers['x-custom-header']).toEqual('CustomValue') + expect(String(response.headers['set-cookie'])).toContain('testCookie=testValue;') + }) + + it('should read the status text from the raw response', async () => { + let statusText: string | undefined + + app.get('/test', async (_req, reply) => { + const response = new Resource({ id: 1 }, reply).response() + reply.raw.statusMessage = 'Fine' + statusText = response.statusText() + + return await response + }) + + await app.inject().get('/test') + + expect(statusText).toEqual('Fine') + }) + + it('should include pagination meta with the request path', async () => { + app.get('/users', async (req, reply) => { + return await new ResourceCollection({ + data: [{ id: 1 }], + pagination: { currentPage: 1, total: 10 }, + }, { req, res: reply }).json() + }) + + const response = await app.inject().get('/users') + + expect(response.json()).toEqual({ + data: [{ id: 1 }], + meta: { + current_page: 1, + total: 10, + path: '/users?page=1', + }, + }) + }) + + it('should allow class-level withResponse hook to customize headers/status/body', async () => { + class CustomResource extends Resource { + withResponse(response: ServerResponse) { + response.header('X-From-Hook', '1').setStatusCode(202) + + this.setBody({ ...this.getBody(), meta: { fromWithResponse: true } }) + } + } + + app.get('/test', async (_req, reply) => { + return await new CustomResource({ id: 1 }, reply).json() + }) + + const response = await app.inject().get('/test') + + expect(response.statusCode).toEqual(202) + expect(response.headers['x-from-hook']).toEqual('1') + expect(response.json()).toEqual({ data: { id: 1 }, meta: { fromWithResponse: true } }) + }) +}) From 8118365b1ea023d32f9e0610106f065121b63a14 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 <52163001+3m1n3nc3@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:15:09 +0000 Subject: [PATCH 2/2] fix(plugin-clear-router): return the sent Fastify reply to Fastify --- packages/plugin-clear-router/src/index.ts | 5 ++++- .../plugin-clear-router/tests/fastify.spec.ts | 20 ++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/plugin-clear-router/src/index.ts b/packages/plugin-clear-router/src/index.ts index 0038cd6..6be1332 100644 --- a/packages/plugin-clear-router/src/index.ts +++ b/packages/plugin-clear-router/src/index.ts @@ -102,7 +102,10 @@ export const clearRouterFastifyPlugin = definePlugin({ patchRouter(ClearRouterFastify as never, runWithCtx, (ctx: any) => ({ ...ctx, res: ctx.reply, - })) + }), (ctx: any, _result, resolved) => { + // Fastify expects the reply itself back once a handler has sent it. + return ctx.reply?.sent ? ctx.reply : resolved + }) }, }) diff --git a/packages/plugin-clear-router/tests/fastify.spec.ts b/packages/plugin-clear-router/tests/fastify.spec.ts index 4c215c2..faaf931 100644 --- a/packages/plugin-clear-router/tests/fastify.spec.ts +++ b/packages/plugin-clear-router/tests/fastify.spec.ts @@ -1,5 +1,5 @@ import { Resource, registerPlugin, resetPluginsForTests } from '../../../src' -import { beforeEach, describe, it } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' import fastify, { FastifyInstance } from 'fastify' import { Router as ClearRouter } from 'clear-router/fastify' @@ -70,4 +70,22 @@ describe('@resora/plugin-clear-router fastify', () => { }, }) }) + + it('hands the sent reply back to fastify so it is not sent twice', async () => { + const warnings: unknown[] = [] + + app = fastify({ logger: { level: 'warn', stream: { write: (line: string) => warnings.push(line) } } }) + + ClearRouter.get('/users/3', () => { + return new Resource({ id: 3, name: 'Linus' }) + }) + + await setup() + + await request(app).get('/users/3') + .expect(200) + .expect({ data: { id: 3, name: 'Linus' } }) + + expect(warnings.join('')).not.toContain('FST_ERR_REP_ALREADY_SENT') + }) })