BREAKING CHANGE(request): introduce lazy request body parsing via ctx.json()/text()/arrayBuffer()/formData and remove IRequestContext.body

This commit is contained in:
2025-12-20 06:55:47 +00:00
parent 39f0cdf380
commit 4a17bf39c6
9 changed files with 191 additions and 65 deletions

View File

@@ -10,12 +10,106 @@ import type {
IRequestContext,
IConnectionInfo,
THttpMethod,
TRuntime,
IInterceptOptions,
TRequestInterceptor,
TResponseInterceptor,
IWebSocketPeer,
IWebSocketConnectionCallbacks,
} from './smartserve.interfaces.js';
// =============================================================================
// RequestContext - Implements lazy body parsing
// =============================================================================
interface IBodyCache<T> {
done: boolean;
value?: T;
error?: Error;
}
/**
* Request context implementation with lazy body parsing.
* Body is never consumed automatically - use json(), text(), etc. when needed.
*/
class RequestContext<TBody = unknown> implements IRequestContext<TBody> {
private _jsonCache: IBodyCache<TBody> = { done: false };
private _textCache: IBodyCache<string> = { done: false };
private _arrayBufferCache: IBodyCache<ArrayBuffer> = { done: false };
private _formDataCache: IBodyCache<FormData> = { done: false };
constructor(
public readonly request: Request,
public readonly params: Record<string, string>,
public readonly query: Record<string, string>,
public readonly headers: Headers,
public readonly path: string,
public readonly method: THttpMethod,
public readonly url: URL,
public readonly runtime: TRuntime,
public state: Record<string, unknown> = {},
) {}
async json(): Promise<TBody> {
if (this._jsonCache.done) {
if (this._jsonCache.error) throw this._jsonCache.error;
return this._jsonCache.value!;
}
try {
const value = await this.request.json() as TBody;
this._jsonCache = { done: true, value };
return value;
} catch (e) {
this._jsonCache = { done: true, error: e as Error };
throw e;
}
}
async text(): Promise<string> {
if (this._textCache.done) {
if (this._textCache.error) throw this._textCache.error;
return this._textCache.value!;
}
try {
const value = await this.request.text();
this._textCache = { done: true, value };
return value;
} catch (e) {
this._textCache = { done: true, error: e as Error };
throw e;
}
}
async arrayBuffer(): Promise<ArrayBuffer> {
if (this._arrayBufferCache.done) {
if (this._arrayBufferCache.error) throw this._arrayBufferCache.error;
return this._arrayBufferCache.value!;
}
try {
const value = await this.request.arrayBuffer();
this._arrayBufferCache = { done: true, value };
return value;
} catch (e) {
this._arrayBufferCache = { done: true, error: e as Error };
throw e;
}
}
async formData(): Promise<FormData> {
if (this._formDataCache.done) {
if (this._formDataCache.error) throw this._formDataCache.error;
return this._formDataCache.value!;
}
try {
const value = await this.request.formData();
this._formDataCache = { done: true, value };
return value;
} catch (e) {
this._formDataCache = { done: true, error: e as Error };
throw e;
}
}
}
import { HttpError, RouteNotFoundError, ServerAlreadyRunningError, WebSocketConfigError } from './smartserve.errors.js';
import { AdapterFactory, type BaseAdapter, type TRequestHandler } from '../adapters/index.js';
import { ControllerRegistry, type ICompiledRoute, type IRouteCompressionOptions } from '../decorators/index.js';
@@ -345,8 +439,8 @@ export class SmartServe {
const { route, params } = match;
try {
// Create request context
const context = await this.createContext(request, url, params, connectionInfo);
// Create request context (body parsing is lazy via ctx.json(), ctx.text(), etc.)
const context = this.createContext(request, url, params, connectionInfo);
// Run interceptors and handler
const response = await this.executeRoute(route, context);
@@ -360,59 +454,32 @@ export class SmartServe {
}
/**
* Create request context from Request object
* Create request context from Request object.
* Body is NOT parsed here - use ctx.json(), ctx.text(), etc. for lazy parsing.
*/
private async createContext(
private createContext(
request: Request,
url: URL,
params: Record<string, string>,
connectionInfo: IConnectionInfo
): Promise<IRequestContext> {
): IRequestContext {
// Parse query params
const query: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
query[key] = value;
});
// Parse body (lazy)
let body: any = undefined;
const contentType = request.headers.get('content-type');
if (request.method !== 'GET' && request.method !== 'HEAD') {
if (contentType?.includes('application/json')) {
try {
body = await request.json();
} catch {
body = null;
}
} else if (contentType?.includes('application/x-www-form-urlencoded')) {
try {
const text = await request.text();
body = Object.fromEntries(new URLSearchParams(text));
} catch {
body = null;
}
} else if (contentType?.includes('text/')) {
try {
body = await request.text();
} catch {
body = null;
}
}
}
return {
return new RequestContext(
request,
body,
params,
query,
headers: request.headers,
path: url.pathname,
method: request.method.toUpperCase() as THttpMethod,
request.headers,
url.pathname,
request.method.toUpperCase() as THttpMethod,
url,
runtime: this.adapter?.name ?? 'node',
state: {},
};
this.adapter?.name ?? 'node',
{},
);
}
/**

View File

@@ -24,10 +24,8 @@ export type TRuntime = 'node' | 'deno' | 'bun';
* Wraps Web Standard Request with additional utilities
*/
export interface IRequestContext<TBody = unknown> {
/** Original Web Standards Request */
/** Original Web Standards Request - body stream is never consumed by framework */
readonly request: Request;
/** Parsed request body (typed) */
readonly body: TBody;
/** URL path parameters extracted from route */
readonly params: Record<string, string>;
/** URL query parameters */
@@ -44,6 +42,34 @@ export interface IRequestContext<TBody = unknown> {
readonly runtime: TRuntime;
/** Route-specific state bag for passing data between interceptors */
state: Record<string, unknown>;
/**
* Lazily parse request body as JSON.
* Result is cached after first call.
* @returns Typed body parsed from JSON
*/
json(): Promise<TBody>;
/**
* Lazily parse request body as text.
* Result is cached after first call.
* @returns Body as string
*/
text(): Promise<string>;
/**
* Lazily parse request body as ArrayBuffer.
* Result is cached after first call.
* @returns Body as ArrayBuffer
*/
arrayBuffer(): Promise<ArrayBuffer>;
/**
* Lazily parse request body as FormData.
* Result is cached after first call.
* @returns Body as FormData
*/
formData(): Promise<FormData>;
}
// =============================================================================