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

@@ -1,5 +1,15 @@
# Changelog # Changelog
## 2025-12-20 - 2.0.0 - BREAKING CHANGE(request)
introduce lazy request body parsing via ctx.json()/text()/arrayBuffer()/formData and remove IRequestContext.body
- Add RequestContext class implementing lazy, cached body parsing methods: json(), text(), arrayBuffer(), formData.
- Remove IRequestContext.body property — handlers and interceptors must call ctx.json()/ctx.text()/... to access the request body (breaking API change).
- createContext now returns a RequestContext synchronously and no longer pre-parses or coerces the body.
- OpenAPI validator (validateRequest) made async and updated to use ctx.json() for request body validation; createValidationInterceptor now awaits validation.
- Updated README and tests to use async handlers and ctx.json() for body access.
- Updated npmextra.json: replaced gitzone key with @git.zone/cli, replaced npmci with @ship.zone/szci, and added release registries and accessLevel.
## 2025-12-08 - 1.4.0 - feat(openapi) ## 2025-12-08 - 1.4.0 - feat(openapi)
Add OpenAPI module: decorators, spec generator, runtime validation and Swagger UI Add OpenAPI module: decorators, spec generator, runtime validation and Swagger UI

View File

@@ -1,5 +1,5 @@
{ {
"gitzone": { "@git.zone/cli": {
"projectType": "npm", "projectType": "npm",
"module": { "module": {
"githost": "code.foss.global", "githost": "code.foss.global",
@@ -9,10 +9,16 @@
"npmPackagename": "@push.rocks/smartserve", "npmPackagename": "@push.rocks/smartserve",
"license": "MIT", "license": "MIT",
"projectDomain": "push.rocks" "projectDomain": "push.rocks"
},
"release": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
} }
}, },
"npmci": { "@ship.zone/szci": {
"npmGlobalTools": [], "npmGlobalTools": []
"npmAccessLevel": "public"
} }
} }

View File

@@ -44,8 +44,9 @@ class UserController {
} }
@Post('/users') @Post('/users')
createUser(ctx: IRequestContext<{ name: string; email: string }>) { async createUser(ctx: IRequestContext<{ name: string; email: string }>) {
return { id: 'new-id', ...ctx.body }; const body = await ctx.json();
return { id: 'new-id', ...body };
} }
} }
@@ -75,8 +76,9 @@ class ApiController {
} }
@Post('/items') // POST /api/v1/items @Post('/items') // POST /api/v1/items
createItem(ctx: IRequestContext<{ name: string }>) { async createItem(ctx: IRequestContext<{ name: string }>) {
return { created: ctx.body.name }; const body = await ctx.json();
return { created: body.name };
} }
@Put('/items/:id') // PUT /api/v1/items/:id @Put('/items/:id') // PUT /api/v1/items/:id
@@ -345,8 +347,7 @@ Every handler receives a typed request context:
```typescript ```typescript
interface IRequestContext<TBody = unknown> { interface IRequestContext<TBody = unknown> {
request: Request; // Original Web Standards Request request: Request; // Original Web Standards Request (body never consumed by framework)
body: TBody; // Parsed and typed body
params: Record<string, string>; // URL path parameters params: Record<string, string>; // URL path parameters
query: Record<string, string>; // Query string parameters query: Record<string, string>; // Query string parameters
headers: Headers; // Request headers headers: Headers; // Request headers
@@ -355,9 +356,17 @@ interface IRequestContext<TBody = unknown> {
url: URL; // Full URL object url: URL; // Full URL object
runtime: 'node' | 'deno' | 'bun'; // Current runtime runtime: 'node' | 'deno' | 'bun'; // Current runtime
state: Record<string, unknown>; // Per-request state bag state: Record<string, unknown>; // Per-request state bag
// Lazy body parsing methods (cached after first call)
json(): Promise<TBody>; // Parse body as JSON (typed)
text(): Promise<string>; // Parse body as text
arrayBuffer(): Promise<ArrayBuffer>; // Parse body as binary
formData(): Promise<FormData>; // Parse body as form data
} }
``` ```
Body parsing is lazy - the request body is only consumed when you call `json()`, `text()`, etc. This allows raw access to `ctx.request` for cases like signature verification.
## Custom Request Handler ## Custom Request Handler
Bypass decorator routing entirely: Bypass decorator routing entirely:

View File

@@ -95,11 +95,12 @@ class OpenApiUserController {
}) })
@ApiResponseBody(201, { description: 'User created', schema: UserSchema }) @ApiResponseBody(201, { description: 'User created', schema: UserSchema })
@ApiResponseBody(400, { description: 'Validation error' }) @ApiResponseBody(400, { description: 'Validation error' })
createUser(ctx: IRequestContext<{ name: string; email: string }>) { async createUser(ctx: IRequestContext<{ name: string; email: string }>) {
const body = await ctx.json();
return { return {
id: 'new-uuid', id: 'new-uuid',
name: ctx.body.name, name: body.name,
email: ctx.body.email, email: body.email,
}; };
} }

View File

@@ -69,8 +69,9 @@ class TestController {
} }
@Post('/echo') @Post('/echo')
echo(ctx: IRequestContext<{ text: string }>) { async echo(ctx: IRequestContext<{ text: string }>) {
return { echo: ctx.body?.text }; const body = await ctx.json();
return { echo: body?.text };
} }
} }

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartserve', name: '@push.rocks/smartserve',
version: '1.4.0', version: '2.0.0',
description: 'a cross platform server module for Node, Deno and Bun' description: 'a cross platform server module for Node, Deno and Bun'
} }

View File

@@ -10,12 +10,106 @@ import type {
IRequestContext, IRequestContext,
IConnectionInfo, IConnectionInfo,
THttpMethod, THttpMethod,
TRuntime,
IInterceptOptions, IInterceptOptions,
TRequestInterceptor, TRequestInterceptor,
TResponseInterceptor, TResponseInterceptor,
IWebSocketPeer, IWebSocketPeer,
IWebSocketConnectionCallbacks, IWebSocketConnectionCallbacks,
} from './smartserve.interfaces.js'; } 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 { HttpError, RouteNotFoundError, ServerAlreadyRunningError, WebSocketConfigError } from './smartserve.errors.js';
import { AdapterFactory, type BaseAdapter, type TRequestHandler } from '../adapters/index.js'; import { AdapterFactory, type BaseAdapter, type TRequestHandler } from '../adapters/index.js';
import { ControllerRegistry, type ICompiledRoute, type IRouteCompressionOptions } from '../decorators/index.js'; import { ControllerRegistry, type ICompiledRoute, type IRouteCompressionOptions } from '../decorators/index.js';
@@ -345,8 +439,8 @@ export class SmartServe {
const { route, params } = match; const { route, params } = match;
try { try {
// Create request context // Create request context (body parsing is lazy via ctx.json(), ctx.text(), etc.)
const context = await this.createContext(request, url, params, connectionInfo); const context = this.createContext(request, url, params, connectionInfo);
// Run interceptors and handler // Run interceptors and handler
const response = await this.executeRoute(route, context); 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, request: Request,
url: URL, url: URL,
params: Record<string, string>, params: Record<string, string>,
connectionInfo: IConnectionInfo connectionInfo: IConnectionInfo
): Promise<IRequestContext> { ): IRequestContext {
// Parse query params // Parse query params
const query: Record<string, string> = {}; const query: Record<string, string> = {};
url.searchParams.forEach((value, key) => { url.searchParams.forEach((value, key) => {
query[key] = value; query[key] = value;
}); });
// Parse body (lazy) return new RequestContext(
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 {
request, request,
body,
params, params,
query, query,
headers: request.headers, request.headers,
path: url.pathname, url.pathname,
method: request.method.toUpperCase() as THttpMethod, request.method.toUpperCase() as THttpMethod,
url, url,
runtime: this.adapter?.name ?? 'node', this.adapter?.name ?? 'node',
state: {}, {},
}; );
} }
/** /**

View File

@@ -24,10 +24,8 @@ export type TRuntime = 'node' | 'deno' | 'bun';
* Wraps Web Standard Request with additional utilities * Wraps Web Standard Request with additional utilities
*/ */
export interface IRequestContext<TBody = unknown> { export interface IRequestContext<TBody = unknown> {
/** Original Web Standards Request */ /** Original Web Standards Request - body stream is never consumed by framework */
readonly request: Request; readonly request: Request;
/** Parsed request body (typed) */
readonly body: TBody;
/** URL path parameters extracted from route */ /** URL path parameters extracted from route */
readonly params: Record<string, string>; readonly params: Record<string, string>;
/** URL query parameters */ /** URL query parameters */
@@ -44,6 +42,34 @@ export interface IRequestContext<TBody = unknown> {
readonly runtime: TRuntime; readonly runtime: TRuntime;
/** Route-specific state bag for passing data between interceptors */ /** Route-specific state bag for passing data between interceptors */
state: Record<string, unknown>; 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>;
} }
// ============================================================================= // =============================================================================

View File

@@ -116,15 +116,15 @@ export function createValidationErrorResponse(
* Validate the full request based on OpenAPI metadata * Validate the full request based on OpenAPI metadata
* Returns a Response if validation fails, undefined if valid * Returns a Response if validation fails, undefined if valid
*/ */
export function validateRequest( export async function validateRequest(
ctx: IRequestContext, ctx: IRequestContext,
openapi: IOpenApiRouteMeta openapi: IOpenApiRouteMeta
): { ): Promise<{
valid: boolean; valid: boolean;
response?: Response; response?: Response;
coercedParams?: Record<string, unknown>; coercedParams?: Record<string, unknown>;
coercedQuery?: Record<string, unknown>; coercedQuery?: Record<string, unknown>;
} { }> {
const allErrors: Array<{ errors: IValidationError[]; source: string }> = []; const allErrors: Array<{ errors: IValidationError[]; source: string }> = [];
// Coerce and validate path parameters // Coerce and validate path parameters
@@ -164,10 +164,16 @@ export function validateRequest(
} }
} }
// Validate request body // Validate request body (lazy parsing via ctx.json())
if (openapi.requestBody) { if (openapi.requestBody) {
const required = openapi.requestBody.required !== false; const required = openapi.requestBody.required !== false;
const body = ctx.body; let body: unknown;
try {
body = await ctx.json();
} catch {
body = undefined;
}
if (required && (body === undefined || body === null)) { if (required && (body === undefined || body === null)) {
allErrors.push({ allErrors.push({
@@ -210,7 +216,7 @@ export function validateRequest(
*/ */
export function createValidationInterceptor(openapi: IOpenApiRouteMeta) { export function createValidationInterceptor(openapi: IOpenApiRouteMeta) {
return async (ctx: IRequestContext): Promise<IRequestContext | Response | void> => { return async (ctx: IRequestContext): Promise<IRequestContext | Response | void> => {
const result = validateRequest(ctx, openapi); const result = await validateRequest(ctx, openapi);
if (!result.valid && result.response) { if (!result.valid && result.response) {
return result.response; return result.response;