From 0dc7b87ed0cfeb92aaaa1e0b0f783e5c82750fde Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Wed, 15 Jul 2026 07:08:17 +0100 Subject: [PATCH] fix: defer responses for async resource data --- src/BaseSerializer.ts | 40 +++++++++++++++++++++--------- src/GenericResource.ts | 24 +++++++++++++++--- src/Resource.ts | 10 +++++--- src/ResourceCollection.ts | 24 +++++++++++++++--- src/ServerResponse.ts | 52 +++++++++++++++++++++++++++------------ src/utilities/arkorm.ts | 24 ++++++++++++++++++ tests/express.spec.ts | 39 +++++++++++++++++++++++++++-- 7 files changed, 173 insertions(+), 40 deletions(-) diff --git a/src/BaseSerializer.ts b/src/BaseSerializer.ts index b34e5df..2728add 100644 --- a/src/BaseSerializer.ts +++ b/src/BaseSerializer.ts @@ -119,6 +119,13 @@ export abstract class BaseSerializer { protected abstract getSerializerType (): ResponseKind protected abstract setBody (body: any): this + /** + * Report whether a synchronous serialization pass encountered async data. + */ + isSerializationPending (): boolean { + return this.called.json === false + } + /** * Apply registered plugins for the serialization process, allowing plugins to * modify the response body and metadata before the response is sent. @@ -279,6 +286,7 @@ export abstract class BaseSerializer { */ protected runResponse (input: { ensureJson: () => void + ensureJsonAsync?: () => Promise rawResponse: TRawResponse body: () => TBody createServerResponse: (raw: TRawResponse, body: TBody) => TServerResponse @@ -287,21 +295,31 @@ export abstract class BaseSerializer { this.called.toResponse = true input.ensureJson() - const resolvedBody = input.body() - const response = input.createServerResponse(input.rawResponse, resolvedBody) + const response = input.createServerResponse(input.rawResponse, input.body()) + const finalizeResponse = () => { + this.called.withResponse = true + input.callWithResponse(response, input.rawResponse) - this.called.withResponse = true - input.callWithResponse(response, input.rawResponse) + if (typeof (response as any)?.setBody === 'function') { + (response as any).setBody(input.body()) + } - if (typeof (response as any)?.setBody === 'function') { - (response as any).setBody(input.body()) + return this.applyResponsePlugins({ + body: input.body(), + rawResponse: input.rawResponse, + response, + }) } - this.applyResponsePlugins({ - body: input.body(), - rawResponse: input.rawResponse, - response, - }) + if (!this.called.json && input.ensureJsonAsync && typeof (response as any)?.setBodyResolver === 'function') { + (response as any).setBodyResolver(async () => { + await input.ensureJsonAsync!() + + return finalizeResponse() + }) + } else { + finalizeResponse() + } return response } diff --git a/src/GenericResource.ts b/src/GenericResource.ts index 8db6a38..5c22931 100644 --- a/src/GenericResource.ts +++ b/src/GenericResource.ts @@ -23,9 +23,9 @@ import { getPaginationExtraKeys, isArkormLikeCollection, isArkormLikeModel, - isPromiseLike, normalizeSerializableData, normalizeSerializableDataAsync, + requiresAsyncNormalization, sanitizeConditionalAttributes, setRequestUrl, transformKeys, @@ -44,6 +44,8 @@ export class GenericResource< > extends BaseSerializer { [key: string]: any; private body: GenericBody = { data: {} as any } + private pendingData?: unknown + private pendingDataCollected = false private res?: Response public resource: R public collects?: typeof Resource @@ -242,7 +244,8 @@ export class GenericResource< const ctx = this.resolveSerializationContext() const resource = this.data(ctx) - if (isPromiseLike(resource)) { + if (requiresAsyncNormalization(resource)) { + this.pendingData = resource this.called.json = false return this @@ -254,6 +257,14 @@ export class GenericResource< data = data.map(item => new this.collects!(item).data(ctx)) } + if (requiresAsyncNormalization(data)) { + this.pendingData = data + this.pendingDataCollected = true + this.called.json = false + + return this + } + if (!Array.isArray(data) && data && typeof data.data !== 'undefined') { data = data.data } @@ -313,11 +324,15 @@ export class GenericResource< this.called.json = true const ctx = this.resolveSerializationContext() - const resource = await this.data(ctx) + const hasPendingData = typeof this.pendingData !== 'undefined' + const pendingDataCollected = this.pendingDataCollected + const resource = hasPendingData ? this.pendingData : this.data(ctx) + this.pendingData = undefined + this.pendingDataCollected = false let data: any = await normalizeSerializableDataAsync(resource) - if (Array.isArray(data) && this.collects) { + if (Array.isArray(data) && this.collects && !pendingDataCollected) { data = await Promise.all(data.map(async item => new this.collects!(item).data(ctx))) data = await normalizeSerializableDataAsync(data) } @@ -458,6 +473,7 @@ export class GenericResource< return this.runResponse({ ensureJson: () => this.json(), + ensureJsonAsync: () => this.jsonAsync(), rawResponse, body: () => this.body, createServerResponse: (raw, body) => { diff --git a/src/Resource.ts b/src/Resource.ts index 038ea02..9f259f8 100644 --- a/src/Resource.ts +++ b/src/Resource.ts @@ -19,9 +19,9 @@ import { extractResponseFromCtx, getCaseTransformer, isArkormLikeModel, - isPromiseLike, normalizeSerializableData, normalizeSerializableDataAsync, + requiresAsyncNormalization, sanitizeConditionalAttributes, setRequestUrl, transformKeys, @@ -38,6 +38,7 @@ import { export class Resource extends BaseSerializer { [key: string]: any; private body: ResourceBody = { data: {} as any } + private pendingData?: unknown private res?: Response public resource: R protected withResponseContext?: { @@ -196,7 +197,8 @@ export class Resource ex const ctx = this.resolveSerializationContext() const resource = this.data(ctx) - if (isPromiseLike(resource)) { + if (requiresAsyncNormalization(resource)) { + this.pendingData = resource this.called.json = false return this @@ -249,7 +251,8 @@ export class Resource ex this.called.json = true const ctx = this.resolveSerializationContext() - const resource = await this.data(ctx) + const resource = this.pendingData ?? this.data(ctx) + this.pendingData = undefined let data: any = await normalizeSerializableDataAsync(resource) @@ -364,6 +367,7 @@ export class Resource ex return this.runResponse({ ensureJson: () => this.json(), + ensureJsonAsync: () => this.jsonAsync(), rawResponse, body: () => this.body, createServerResponse: (raw, body) => { diff --git a/src/ResourceCollection.ts b/src/ResourceCollection.ts index ef74125..4700f06 100644 --- a/src/ResourceCollection.ts +++ b/src/ResourceCollection.ts @@ -21,9 +21,9 @@ import { getCaseTransformer, getPaginationExtraKeys, isArkormLikeCollection, - isPromiseLike, normalizeSerializableData, normalizeSerializableDataAsync, + requiresAsyncNormalization, sanitizeConditionalAttributes, setRequestUrl, transformKeys, @@ -43,6 +43,8 @@ export class ResourceCollection< > extends BaseSerializer { [key: string]: any; private body: CollectionBody = { data: [] as any } + private pendingData?: unknown + private pendingDataCollected = false private res?: Response public resource: R public collects?: typeof Resource @@ -252,7 +254,8 @@ export class ResourceCollection< const ctx = this.resolveSerializationContext() let data: ResourceData[] = this.data(ctx) as never - if (isPromiseLike(data)) { + if (requiresAsyncNormalization(data)) { + this.pendingData = data this.called.json = false return this @@ -262,6 +265,14 @@ export class ResourceCollection< data = data.map((item: any) => new this.collects!(item).data(ctx)) } + if (requiresAsyncNormalization(data)) { + this.pendingData = data + this.pendingDataCollected = true + this.called.json = false + + return this + } + data = normalizeSerializableData(data) as ResourceData[] data = sanitizeConditionalAttributes(data) as ResourceData[] @@ -326,9 +337,13 @@ export class ResourceCollection< this.called.json = true const ctx = this.resolveSerializationContext() - let data: ResourceData[] = await this.data(ctx) as never + const hasPendingData = typeof this.pendingData !== 'undefined' + const pendingDataCollected = this.pendingDataCollected + let data: ResourceData[] = (hasPendingData ? this.pendingData : await this.data(ctx)) as never + this.pendingData = undefined + this.pendingDataCollected = false - if (this.collects && this.data === ResourceCollection.prototype.data) { + if (this.collects && this.data === ResourceCollection.prototype.data && !pendingDataCollected) { data = await Promise.all(data.map(async (item: any) => new this.collects!(item).data(ctx))) as ResourceData[] } @@ -505,6 +520,7 @@ export class ResourceCollection< return this.runResponse({ ensureJson: () => this.json(), + ensureJsonAsync: () => this.jsonAsync(), rawResponse, body: () => this.body, createServerResponse: (raw, body) => { diff --git a/src/ServerResponse.ts b/src/ServerResponse.ts index 10f9908..050c05f 100644 --- a/src/ServerResponse.ts +++ b/src/ServerResponse.ts @@ -28,6 +28,7 @@ export class ServerResponse< private _status: number = 200 private sent = false private prepared = false + private bodyResolver?: () => R | PromiseLike headers: Record = {} constructor(response: H3Event['res'], body: R) @@ -41,7 +42,7 @@ export class ServerResponse< * @param status * @returns The current ServerResponse instance */ - setStatusCode (status: number) { + setStatusCode(status: number) { this._status = status this.prepared = false @@ -54,19 +55,31 @@ export class ServerResponse< * @param body * @returns The current ServerResponse instance */ - setBody (body: R) { + setBody(body: R) { this.body = body this.prepared = false return this } + /** + * Defer resolving the response body until the response is dispatched. + * + * @param resolver + * @returns + */ + setBodyResolver(resolver: () => R | PromiseLike) { + this.bodyResolver = resolver + + return this + } + /** * Get the current HTTP status code for the response * * @returns */ - status () { + status() { return this._status } @@ -75,7 +88,7 @@ export class ServerResponse< * * @returns */ - statusText () { + statusText() { if (this.response && 'statusMessage' in this.response) { return this.response.statusMessage } else if (this.response && 'statusText' in this.response) { @@ -93,7 +106,7 @@ export class ServerResponse< * @param options Optional cookie attributes (e.g., path, domain, maxAge) * @returns The current ServerResponse instance */ - setCookie (name: string, value: string, options?: Record) { + setCookie(name: string, value: string, options?: Record) { this.#addHeader( 'Set-Cookie', `${name}=${value}; ${Object.entries(options || {}).map(([key, val]) => `${key}=${val}`).join('; ')}` @@ -108,7 +121,7 @@ export class ServerResponse< * @param headers Optional headers to add to the response * @returns The current ServerResponse instance */ - setHeaders (headers: Record) { + setHeaders(headers: Record) { for (const [key, value] of Object.entries(headers)) { this.#addHeader(key, value) } @@ -123,7 +136,7 @@ export class ServerResponse< * @param value The value of the header * @returns The current ServerResponse instance */ - header (key: string, value: string) { + header(key: string, value: string) { this.#addHeader(key, value) return this @@ -135,7 +148,7 @@ export class ServerResponse< * @param key The name of the header * @param value The value of the header */ - #addHeader (key: string, value: string) { + #addHeader(key: string, value: string) { this.headers[key] = value this.prepared = false @@ -161,7 +174,7 @@ export class ServerResponse< * This is the preferred integration boundary for frameworks that own their * response lifecycle. The returned object is deliberately not thenable. */ - toResponseData (): ServerResponseData { + toResponseData(): ServerResponseData { this.#prepare() const statusText = this.statusText() @@ -182,11 +195,18 @@ export class ServerResponse< * @param body Optional body override * @returns The dispatched response body */ - send (body?: R) { + send(body?: R): R | Promise { if (this.sent || this.#rawResponseSent()) { return this.body } + if (typeof body === 'undefined' && this.bodyResolver) { + const resolver = this.bodyResolver + this.bodyResolver = undefined + + return Promise.resolve(resolver()).then(resolvedBody => this.send(resolvedBody)) + } + if (typeof body !== 'undefined') { this.body = body this.prepared = false @@ -238,7 +258,7 @@ export class ServerResponse< return this.body } - #prepare () { + #prepare() { if (this.prepared) { return } @@ -261,7 +281,7 @@ export class ServerResponse< this.prepared = true } - #runAfterSend () { + #runAfterSend() { runPluginHook('afterSend', { response: this, rawResponse: this.response, @@ -273,7 +293,7 @@ export class ServerResponse< }) } - #rawResponseSent () { + #rawResponseSent() { const raw = this.response as any return Boolean(raw?.headersSent || raw?.sent || raw?.raw?.headersSent) @@ -286,7 +306,7 @@ export class ServerResponse< * @param onrejected Callback to handle the rejected state of the promise, receiving the error reason * @returns A promise that resolves to the result of the onfulfilled or onrejected callback */ - then ( + then( onfulfilled?: ((value: R) => TResult1 | PromiseLike) | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | null, ): Promise { @@ -301,7 +321,7 @@ export class ServerResponse< * @param onrejected * @returns */ - catch ( + catch( onrejected?: ((reason: any) => TResult | PromiseLike) | null, ): Promise { return this.then(undefined, onrejected) @@ -313,7 +333,7 @@ export class ServerResponse< * @param onfinally * @returns */ - finally (onfinally?: (() => void) | null) { + finally(onfinally?: (() => void) | null) { return this.then(onfinally, onfinally) } diff --git a/src/utilities/arkorm.ts b/src/utilities/arkorm.ts index bad7d8d..a1e268c 100644 --- a/src/utilities/arkorm.ts +++ b/src/utilities/arkorm.ts @@ -18,6 +18,7 @@ type ResoraCollectionLike = { getBodyAsync?: () => Promise json: () => unknown setCollects: (...args: unknown[]) => unknown + isSerializationPending?: () => boolean } type ResoraSerializerLike = { @@ -25,6 +26,7 @@ type ResoraSerializerLike = { getBodyAsync?: () => Promise json: () => unknown toObject: () => unknown + isSerializationPending?: () => boolean } export const isPromiseLike = (value: unknown): value is PromiseLike => { @@ -33,6 +35,28 @@ export const isPromiseLike = (value: unknown): value is PromiseLike => && typeof (value as PromiseLike).then === 'function' } +export const requiresAsyncNormalization = (value: unknown): boolean => { + if (isResoraCollectionLike(value) || isResoraSerializerLike(value)) { + value.getBody() + + return value.isSerializationPending?.() ?? false + } + + if (isPromiseLike(value)) { + return true + } + + if (Array.isArray(value)) { + return value.some(item => requiresAsyncNormalization(item)) + } + + if (isPlainObject(value)) { + return Object.values(value).some(item => requiresAsyncNormalization(item)) + } + + return false +} + const unwrapNestedSerializerBody = (body: unknown) => { if (isPlainObject(body) && 'data' in body) { return body.data diff --git a/tests/express.spec.ts b/tests/express.spec.ts index 1458f98..d71638a 100644 --- a/tests/express.spec.ts +++ b/tests/express.spec.ts @@ -1,3 +1,4 @@ +import { ArkormCollection, Model } from 'arkormx' import { Resource, ServerResponse } from 'src' import { beforeEach, describe, expect, it } from 'vitest' @@ -291,6 +292,24 @@ describe('Connect-style Requests (Express)', () => { }) it('should support async resource data in express responses', async () => { + class ProfileModel extends Model<{ id: number, displayName: string }> { + } + + const models = new ArkormCollection([ + new ProfileModel({ id: 1, displayName: 'A' }), + new ProfileModel({ id: 2, displayName: 'B' }), + ]) + + + class AsyncProfileResource extends Resource { + async data() { + return { + id: this.id, + displayName: this.displayName, + } + } + } + class ProfileResource extends Resource { data() { return { @@ -300,6 +319,14 @@ describe('Connect-style Requests (Express)', () => { } } + class ProfileCollection extends ResourceCollection { + collects = AsyncProfileResource + + data() { + return this.toObject() + } + } + class UserResource extends Resource { async data() { return { @@ -318,10 +345,14 @@ describe('Connect-style Requests (Express)', () => { id: 10, displayName: 'Jane Doe', }, - }, res) + }, res).response().setStatusCode(202) + }) + + app.get('/all/test', async (_, res) => { + return await new ProfileCollection(models, res).response().setStatusCode(202) }) - await request(app).get('/test').expect({ + await request(app).get('/test').expect(202).expect({ data: { id: 1, name: 'Jane', @@ -331,6 +362,10 @@ describe('Connect-style Requests (Express)', () => { }, }, }) + + await request(app).get('/all/test').expect(202).expect({ + data: models.all(), + }) }) it('should allow class-level withResponse hook to customize headers/status/body', async () => {