diff --git a/changelog.md b/changelog.md index caa1927..63b1efa 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # 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) Add OpenAPI module: decorators, spec generator, runtime validation and Swagger UI diff --git a/npmextra.json b/npmextra.json index 9915086..196318f 100644 --- a/npmextra.json +++ b/npmextra.json @@ -1,5 +1,5 @@ { - "gitzone": { + "@git.zone/cli": { "projectType": "npm", "module": { "githost": "code.foss.global", @@ -9,10 +9,16 @@ "npmPackagename": "@push.rocks/smartserve", "license": "MIT", "projectDomain": "push.rocks" + }, + "release": { + "registries": [ + "https://verdaccio.lossless.digital", + "https://registry.npmjs.org" + ], + "accessLevel": "public" } }, - "npmci": { - "npmGlobalTools": [], - "npmAccessLevel": "public" + "@ship.zone/szci": { + "npmGlobalTools": [] } } \ No newline at end of file diff --git a/readme.md b/readme.md index f1f0dfe..d639f31 100644 --- a/readme.md +++ b/readme.md @@ -44,8 +44,9 @@ class UserController { } @Post('/users') - createUser(ctx: IRequestContext<{ name: string; email: string }>) { - return { id: 'new-id', ...ctx.body }; + async createUser(ctx: IRequestContext<{ name: string; email: string }>) { + const body = await ctx.json(); + return { id: 'new-id', ...body }; } } @@ -75,8 +76,9 @@ class ApiController { } @Post('/items') // POST /api/v1/items - createItem(ctx: IRequestContext<{ name: string }>) { - return { created: ctx.body.name }; + async createItem(ctx: IRequestContext<{ name: string }>) { + const body = await ctx.json(); + return { created: body.name }; } @Put('/items/:id') // PUT /api/v1/items/:id @@ -345,8 +347,7 @@ Every handler receives a typed request context: ```typescript interface IRequestContext { - request: Request; // Original Web Standards Request - body: TBody; // Parsed and typed body + request: Request; // Original Web Standards Request (body never consumed by framework) params: Record; // URL path parameters query: Record; // Query string parameters headers: Headers; // Request headers @@ -355,9 +356,17 @@ interface IRequestContext { url: URL; // Full URL object runtime: 'node' | 'deno' | 'bun'; // Current runtime state: Record; // Per-request state bag + + // Lazy body parsing methods (cached after first call) + json(): Promise; // Parse body as JSON (typed) + text(): Promise; // Parse body as text + arrayBuffer(): Promise; // Parse body as binary + formData(): Promise; // 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 Bypass decorator routing entirely: diff --git a/test/test.openapi.ts b/test/test.openapi.ts index c950a28..a889a52 100644 --- a/test/test.openapi.ts +++ b/test/test.openapi.ts @@ -95,11 +95,12 @@ class OpenApiUserController { }) @ApiResponseBody(201, { description: 'User created', schema: UserSchema }) @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 { id: 'new-uuid', - name: ctx.body.name, - email: ctx.body.email, + name: body.name, + email: body.email, }; } diff --git a/test/test.ts b/test/test.ts index 76b4614..55d7f05 100644 --- a/test/test.ts +++ b/test/test.ts @@ -69,8 +69,9 @@ class TestController { } @Post('/echo') - echo(ctx: IRequestContext<{ text: string }>) { - return { echo: ctx.body?.text }; + async echo(ctx: IRequestContext<{ text: string }>) { + const body = await ctx.json(); + return { echo: body?.text }; } } diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index ad34bb2..30c97b1 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartserve', - version: '1.4.0', + version: '2.0.0', description: 'a cross platform server module for Node, Deno and Bun' } diff --git a/ts/core/smartserve.classes.smartserve.ts b/ts/core/smartserve.classes.smartserve.ts index e663d6e..f80bca7 100644 --- a/ts/core/smartserve.classes.smartserve.ts +++ b/ts/core/smartserve.classes.smartserve.ts @@ -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 { + 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 implements IRequestContext { + private _jsonCache: IBodyCache = { done: false }; + private _textCache: IBodyCache = { done: false }; + private _arrayBufferCache: IBodyCache = { done: false }; + private _formDataCache: IBodyCache = { done: false }; + + constructor( + public readonly request: Request, + public readonly params: Record, + public readonly query: Record, + public readonly headers: Headers, + public readonly path: string, + public readonly method: THttpMethod, + public readonly url: URL, + public readonly runtime: TRuntime, + public state: Record = {}, + ) {} + + async json(): Promise { + 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 { + 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 { + 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 { + 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, connectionInfo: IConnectionInfo - ): Promise { + ): IRequestContext { // Parse query params const query: Record = {}; 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', + {}, + ); } /** diff --git a/ts/core/smartserve.interfaces.ts b/ts/core/smartserve.interfaces.ts index 6bcb168..f52b8cb 100644 --- a/ts/core/smartserve.interfaces.ts +++ b/ts/core/smartserve.interfaces.ts @@ -24,10 +24,8 @@ export type TRuntime = 'node' | 'deno' | 'bun'; * Wraps Web Standard Request with additional utilities */ export interface IRequestContext { - /** 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; /** URL query parameters */ @@ -44,6 +42,34 @@ export interface IRequestContext { readonly runtime: TRuntime; /** Route-specific state bag for passing data between interceptors */ state: Record; + + /** + * Lazily parse request body as JSON. + * Result is cached after first call. + * @returns Typed body parsed from JSON + */ + json(): Promise; + + /** + * Lazily parse request body as text. + * Result is cached after first call. + * @returns Body as string + */ + text(): Promise; + + /** + * Lazily parse request body as ArrayBuffer. + * Result is cached after first call. + * @returns Body as ArrayBuffer + */ + arrayBuffer(): Promise; + + /** + * Lazily parse request body as FormData. + * Result is cached after first call. + * @returns Body as FormData + */ + formData(): Promise; } // ============================================================================= diff --git a/ts/openapi/openapi.validator.ts b/ts/openapi/openapi.validator.ts index 1ea84e9..8cf3045 100644 --- a/ts/openapi/openapi.validator.ts +++ b/ts/openapi/openapi.validator.ts @@ -116,15 +116,15 @@ export function createValidationErrorResponse( * Validate the full request based on OpenAPI metadata * Returns a Response if validation fails, undefined if valid */ -export function validateRequest( +export async function validateRequest( ctx: IRequestContext, openapi: IOpenApiRouteMeta -): { +): Promise<{ valid: boolean; response?: Response; coercedParams?: Record; coercedQuery?: Record; -} { +}> { const allErrors: Array<{ errors: IValidationError[]; source: string }> = []; // 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) { 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)) { allErrors.push({ @@ -210,7 +216,7 @@ export function validateRequest( */ export function createValidationInterceptor(openapi: IOpenApiRouteMeta) { return async (ctx: IRequestContext): Promise => { - const result = validateRequest(ctx, openapi); + const result = await validateRequest(ctx, openapi); if (!result.valid && result.response) { return result.response;