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;