BREAKING CHANGE(request): introduce lazy request body parsing via ctx.json()/text()/arrayBuffer()/formData and remove IRequestContext.body
This commit is contained in:
@@ -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',
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user