Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions docs/guide/non-connect-frameworks.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -51,17 +51,37 @@ 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';

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();
});
```

Expand Down
5 changes: 3 additions & 2 deletions docs/guide/server-response.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
>
Expand Down Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion docs/more/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
14 changes: 12 additions & 2 deletions docs/more/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion packages/plugin-clear-router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
},
})

Expand Down
20 changes: 19 additions & 1 deletion packages/plugin-clear-router/tests/fastify.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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')
})
})
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 9 additions & 7 deletions src/GenericResource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { H3Event } from 'h3'
import {
CollectionLike,
Collectible,
FastifyReplyLike,
GenericBody,
MetaData,
NonCollectible,
Expand Down Expand Up @@ -52,7 +53,7 @@ export class GenericResource<
public collects?: typeof Resource<T>
protected withResponseContext?: {
response: ServerResponse<GenericBody<R>>
raw: Response | H3Event['res']
raw: Response | H3Event['res'] | FastifyReplyLike
}

constructor(rsc: R, ctx?: Response | H3Event | Record<string, any>) {
Expand Down Expand Up @@ -473,15 +474,16 @@ export class GenericResource<
*/
response(res: H3Event['res']): ServerResponse<GenericBody<R>>
response(res: Response): ServerResponse<GenericBody<R>>
response(res: FastifyReplyLike): ServerResponse<GenericBody<R>>

/**
* Build a response object, writing to the provided raw response if possible.
*
* @param res
* @returns
*/
response(res?: Response | H3Event['res']): ServerResponse<GenericBody<R>> {
const rawResponse = this.resolveRawResponse(res ?? this.res) as Response | H3Event['res']
response(res?: Response | H3Event['res'] | FastifyReplyLike): ServerResponse<GenericBody<R>> {
const rawResponse = this.resolveRawResponse(res ?? this.res) as Response | H3Event['res'] | FastifyReplyLike

return this.runResponse({
ensureJson: () => this.json(),
Expand Down Expand Up @@ -510,7 +512,7 @@ export class GenericResource<
*/
withResponse(
_response?: ServerResponse<GenericBody<R>>,
_rawResponse?: Response | H3Event['res']
_rawResponse?: Response | H3Event['res'] | FastifyReplyLike
): any {
return this
}
Expand All @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down
16 changes: 9 additions & 7 deletions src/Resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { H3Event } from 'h3'
import {
CollectionLike,
Collectible,
FastifyReplyLike,
MetaData,
NonCollectible,
PaginatorLike,
Expand Down Expand Up @@ -44,7 +45,7 @@ export class Resource<R extends ResourceData | NonCollectible = ResourceData> ex
public resource: R
protected withResponseContext?: {
response: ServerResponse<ResourceBody<R>>
raw: Response | H3Event['res']
raw: Response | H3Event['res'] | FastifyReplyLike
}

constructor(rsc: R, ctx?: Response | H3Event | Record<string, any>) {
Expand Down Expand Up @@ -368,14 +369,15 @@ export class Resource<R extends ResourceData | NonCollectible = ResourceData> ex
*/
response(res: H3Event['res']): ServerResponse<ResourceBody<R>>
response(res: Response): ServerResponse<ResourceBody<R>>
response(res: FastifyReplyLike): ServerResponse<ResourceBody<R>>
/**
* 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<ResourceBody<R>> {
const rawResponse = this.resolveRawResponse(res ?? this.res) as H3Event['res'] | Response
response(res?: H3Event['res'] | Response | FastifyReplyLike): ServerResponse<ResourceBody<R>> {
const rawResponse = this.resolveRawResponse(res ?? this.res) as H3Event['res'] | Response | FastifyReplyLike

return this.runResponse({
ensureJson: () => this.json(),
Expand Down Expand Up @@ -404,7 +406,7 @@ export class Resource<R extends ResourceData | NonCollectible = ResourceData> ex
*/
withResponse(
_response?: ServerResponse<ResourceBody<R>>,
_rawResponse?: Response | H3Event['res']
_rawResponse?: Response | H3Event['res'] | FastifyReplyLike
): any {
return this
}
Expand All @@ -425,7 +427,7 @@ export class Resource<R extends ResourceData | NonCollectible = ResourceData> 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 = {
Expand Down Expand Up @@ -460,7 +462,7 @@ export class Resource<R extends ResourceData | NonCollectible = ResourceData> 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 = {
Expand Down Expand Up @@ -492,7 +494,7 @@ export class Resource<R extends ResourceData | NonCollectible = ResourceData> 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 = {
Expand Down
16 changes: 9 additions & 7 deletions src/ResourceCollection.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { H3Event } from 'h3'
import {
CollectionLike,
FastifyReplyLike,
ResourceData,
Collectible,
CollectionBody,
Expand Down Expand Up @@ -51,7 +52,7 @@ export class ResourceCollection<
public collects?: typeof Resource<T>
protected withResponseContext?: {
response: ServerResponse<CollectionBody<R>>
raw: Response | H3Event['res']
raw: Response | H3Event['res'] | FastifyReplyLike
}

/**
Expand Down Expand Up @@ -521,14 +522,15 @@ export class ResourceCollection<
*/
response(res: H3Event['res']): ServerResponse<CollectionBody<R>>
response(res: Response): ServerResponse<CollectionBody<R>>
response(res: FastifyReplyLike): ServerResponse<CollectionBody<R>>
/**
* 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<CollectionBody<R>> {
const rawResponse = this.resolveRawResponse(res ?? this.res) as H3Event['res'] | Response
response(res?: H3Event['res'] | Response | FastifyReplyLike): ServerResponse<CollectionBody<R>> {
const rawResponse = this.resolveRawResponse(res ?? this.res) as H3Event['res'] | Response | FastifyReplyLike

return this.runResponse({
ensureJson: () => this.json(),
Expand Down Expand Up @@ -557,7 +559,7 @@ export class ResourceCollection<
*/
withResponse(
_response?: ServerResponse<CollectionBody<R>>,
_rawResponse?: Response | H3Event['res']
_rawResponse?: Response | H3Event['res'] | FastifyReplyLike
): any {
return this
}
Expand All @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down
Loading
Loading