@rapidrest/service-core 2.1.1 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/lib/ApiErrors.js +2 -0
  2. package/dist/lib/ApiErrors.js.map +1 -1
  3. package/dist/lib/decorators/RouteDecorators.js +44 -0
  4. package/dist/lib/decorators/RouteDecorators.js.map +1 -1
  5. package/dist/lib/http/MiddlewareChain.js +15 -0
  6. package/dist/lib/http/MiddlewareChain.js.map +1 -1
  7. package/dist/lib/http/bun/BunAdapters.js +35 -0
  8. package/dist/lib/http/bun/BunAdapters.js.map +1 -1
  9. package/dist/lib/http/bun/BunRouter.js +44 -27
  10. package/dist/lib/http/bun/BunRouter.js.map +1 -1
  11. package/dist/lib/http/index.js +3 -3
  12. package/dist/lib/http/index.js.map +1 -1
  13. package/dist/lib/http/types.js.map +1 -1
  14. package/dist/lib/http/uWS/Adapters.js +172 -0
  15. package/dist/lib/http/uWS/Adapters.js.map +1 -1
  16. package/dist/lib/http/uWS/Router.js +55 -29
  17. package/dist/lib/http/uWS/Router.js.map +1 -1
  18. package/dist/lib/models/ModelUtils.js +91 -1
  19. package/dist/lib/models/ModelUtils.js.map +1 -1
  20. package/dist/lib/routes/RouteUtils.js +52 -4
  21. package/dist/lib/routes/RouteUtils.js.map +1 -1
  22. package/dist/types/ApiErrors.d.ts +2 -0
  23. package/dist/types/decorators/RouteDecorators.d.ts +29 -0
  24. package/dist/types/http/MiddlewareChain.d.ts +14 -1
  25. package/dist/types/http/bun/BunAdapters.d.ts +24 -0
  26. package/dist/types/http/bun/BunRouter.d.ts +8 -8
  27. package/dist/types/http/index.d.ts +4 -4
  28. package/dist/types/http/types.d.ts +46 -7
  29. package/dist/types/http/uWS/Adapters.d.ts +88 -0
  30. package/dist/types/http/uWS/Router.d.ts +8 -8
  31. package/dist/types/models/ModelUtils.d.ts +39 -0
  32. package/dist/types/routes/RouteUtils.d.ts +20 -0
  33. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import { JWTUser } from "@rapidrest/core";
2
+ import type { Readable } from "stream";
2
3
  /**
3
4
  * Framework-agnostic HTTP request interface. Mirrors the Express `Request` surface used throughout
4
5
  * this codebase so that route handlers, middleware, and utilities require no changes when the
@@ -13,6 +14,25 @@ export interface HttpRequest {
13
14
  query: Record<string, string | string[]>;
14
15
  body: any;
15
16
  rawBody?: Buffer;
17
+ /**
18
+ * The raw request body as a Node `Readable` stream, populated instead of `body`/`rawBody` for a
19
+ * route registered with `{ streamingBody: true }` (see `HttpRouteOptions`) — e.g. via the
20
+ * `@StreamingBody()` decorator. `undefined` for every ordinary route, where `body`/`rawBody` are
21
+ * populated as before.
22
+ *
23
+ * The stream yields `Buffer` chunks and supports `for await (const chunk of req.bodyStream)` as
24
+ * well as `.pipe()`. Backpressure is enforced end-to-end by both adapters — a slow consumer
25
+ * throttles how fast bytes are read off the underlying connection rather than buffering
26
+ * unbounded data in memory — which is the entire point of opting into streaming (e.g. a
27
+ * multi-GB file upload). The stream is destroyed (with an error) if the client disconnects
28
+ * mid-upload; a handler consuming it via `for await` sees that as a thrown error and should
29
+ * clean up (e.g. delete a partially-written temp file) in a `catch`/`finally`.
30
+ *
31
+ * A streaming route does NOT get the framework's default `maxBodySize` enforcement — that check
32
+ * only runs as part of the ordinary buffering path. The handler is responsible for enforcing
33
+ * whatever size limit makes sense for the route itself.
34
+ */
35
+ bodyStream?: Readable;
16
36
  cookies: Record<string, string>;
17
37
  signedCookies: Record<string, string>;
18
38
  /**
@@ -83,6 +103,25 @@ export type NextFunction = (err?: any) => void;
83
103
  export type RequestHandler = (req: HttpRequest, res: HttpResponse, next: NextFunction) => void | Promise<void>;
84
104
  /** Standard 4-param error-handling middleware function. */
85
105
  export type ErrorHandler = (err: any, req: HttpRequest, res: HttpResponse, next: NextFunction) => void | Promise<void>;
106
+ /**
107
+ * Per-route registration options, optionally passed as the first element of a route's handler list
108
+ * (e.g. `app.post(path, { streamingBody: true }, ...handlers)`). Detected at runtime by both router
109
+ * implementations: a non-function first argument is treated as `HttpRouteOptions` rather than a
110
+ * `RequestHandler`, so omitting it entirely (the overwhelmingly common case) is unaffected — every
111
+ * existing call site that only ever passes handler functions keeps its exact current behavior.
112
+ */
113
+ export interface HttpRouteOptions {
114
+ /**
115
+ * When `true`, the router does not buffer this route's request body into `req.body`/`req.rawBody`
116
+ * before running its middleware/handler chain. Instead the raw body is exposed as a stream on
117
+ * `req.bodyStream` (see its doc comment on `HttpRequest`), and the route's own handler is
118
+ * responsible for consuming it and enforcing any size limit it needs — the framework's
119
+ * `maxBodySize` 413 rejection does not apply to a streaming route. Set by the `@StreamingBody()`
120
+ * route decorator; see `RouteUtils.registerRoute()`.
121
+ */
122
+ streamingBody?: boolean;
123
+ [key: string]: any;
124
+ }
86
125
  /**
87
126
  * Public surface shared by every HTTP router implementation (uWS-backed, Bun-backed, ...).
88
127
  * `Server.ts` depends only on this interface, never on a concrete router class, so the
@@ -90,13 +129,13 @@ export type ErrorHandler = (err: any, req: HttpRequest, res: HttpResponse, next:
90
129
  */
91
130
  export interface IHttpRouter {
92
131
  use(...handlers: RequestHandler[]): this;
93
- get(path: string, ...handlers: RequestHandler[]): this;
94
- post(path: string, ...handlers: RequestHandler[]): this;
95
- put(path: string, ...handlers: RequestHandler[]): this;
96
- delete(path: string, ...handlers: RequestHandler[]): this;
97
- patch(path: string, ...handlers: RequestHandler[]): this;
98
- head(path: string, ...handlers: RequestHandler[]): this;
99
- options(path: string, ...handlers: RequestHandler[]): this;
132
+ get(path: string, ...handlers: Array<RequestHandler | HttpRouteOptions>): this;
133
+ post(path: string, ...handlers: Array<RequestHandler | HttpRouteOptions>): this;
134
+ put(path: string, ...handlers: Array<RequestHandler | HttpRouteOptions>): this;
135
+ delete(path: string, ...handlers: Array<RequestHandler | HttpRouteOptions>): this;
136
+ patch(path: string, ...handlers: Array<RequestHandler | HttpRouteOptions>): this;
137
+ head(path: string, ...handlers: Array<RequestHandler | HttpRouteOptions>): this;
138
+ options(path: string, ...handlers: Array<RequestHandler | HttpRouteOptions>): this;
100
139
  /** `true` if the application has registered its own literal `OPTIONS` route at `path` (as
101
140
  * opposed to the framework's own internal `/*` CORS-preflight fallback registered in `listen()`).
102
141
  * Consulted by `Server.ts`'s global CORS middleware so a real app-defined `OPTIONS` handler gets a
@@ -1,5 +1,6 @@
1
1
  import type { HttpRequest, HttpResponse } from "../types.js";
2
2
  import type { HttpRequest as UWSHttpRequest, HttpResponse as UWSHttpResponse } from "uWebSockets.js";
3
+ import { Readable } from "stream";
3
4
  /** Parses a `cookie` header string into a key/value map. */
4
5
  export declare function parseCookies(cookieHeader: string): Record<string, string>;
5
6
  /**
@@ -70,9 +71,23 @@ export declare class UWSResponse implements HttpResponse {
70
71
  isHead: boolean;
71
72
  /** Intermediate result passed between middleware. */
72
73
  result?: any;
74
+ /**
75
+ * Set by the router via `attachBodyStream()` for a streaming-body route (see
76
+ * `HttpRouteOptions.streamingBody`). `end()` checks `isBodyStreamFullyReceived()` against it —
77
+ * see that function's doc comment and `end()` for the full rationale.
78
+ */
79
+ private _bodyStream?;
73
80
  /** Allow arbitrary per-response properties. */
74
81
  [key: string]: any;
75
82
  constructor(uwsRes: UWSHttpResponse);
83
+ /**
84
+ * Associates this response with a streaming-body route's `req.bodyStream`, so `end()` can detect
85
+ * a response finalizing before uWS has received the whole declared request body and force the
86
+ * connection closed instead of hanging it — see `isBodyStreamFullyReceived()`'s doc comment for
87
+ * the full rationale. Called once, by the router, right after `makeBodyStream()` creates the
88
+ * stream (`HttpRouteOptions.streamingBody` routes only — never called otherwise).
89
+ */
90
+ attachBodyStream(stream: Readable): void;
76
91
  get statusCode(): number;
77
92
  get headersSent(): boolean;
78
93
  get writableEnded(): boolean;
@@ -136,3 +151,76 @@ export declare const DEFAULT_MAX_BODY_SIZE: number;
136
151
  export declare function readBody(uwsRes: UWSHttpResponse, req: UWSRequest, maxBodySize?: number, res?: {
137
152
  onAbort: (callback: () => void) => void;
138
153
  }): Promise<boolean>;
154
+ /**
155
+ * Internal buffer size (in bytes) at which `makeBodyStream()`'s stream applies backpressure — i.e.
156
+ * pauses pulling more bytes off the uWS connection until its consumer catches up. Chosen much larger
157
+ * than a Node `Readable`'s 16 KiB default: a streaming route exists specifically to move large
158
+ * payloads (e.g. a multi-GB mailbox import) efficiently, and throttling every 16 KiB would make
159
+ * pause()/resume() churn dominate. 1 MiB bounds worst-case extra memory per in-flight streaming
160
+ * request to a small, predictable amount while still giving a slow consumer (e.g. writing to disk)
161
+ * plenty of headroom before the network is throttled.
162
+ */
163
+ export declare const STREAMING_BODY_HIGH_WATER_MARK: number;
164
+ /**
165
+ * Returns `true` once uWS has delivered the final chunk of `stream`'s request body — i.e. it is safe
166
+ * to let the underlying connection return to uWS's keep-alive pool — regardless of whether anything
167
+ * has actually consumed the stream. Returns `true` for `undefined` (nothing to wait for) so callers
168
+ * can pass `req.bodyStream` directly without an existence check first.
169
+ *
170
+ * See `UWSResponse.end()`: a streaming route's handler can legitimately respond (an auth failure, a
171
+ * validation error, anything) without ever reading `req.bodyStream`. If the client declared a large
172
+ * `Content-Length` and hasn't actually sent it all yet — or, in the malicious case, never intends to
173
+ * — a plain `uwsRes.end()` at that point leaves the keep-alive connection open indefinitely: uWS
174
+ * won't consider it clean for reuse until it has received every byte of the declared body, and nothing
175
+ * in that scenario ever makes that happen. `end()` uses this check to force the connection closed
176
+ * instead whenever it would otherwise leave that promise unfulfilled.
177
+ */
178
+ export declare function isBodyStreamFullyReceived(stream: Readable | undefined): boolean;
179
+ /**
180
+ * Exposes a uWS request body as a Node `Readable` stream instead of buffering it into memory, for a
181
+ * route registered with `{ streamingBody: true }` (see `HttpRouteOptions`). Does NOT populate
182
+ * `req.body`/`req.rawBody` and does NOT enforce any `maxBodySize` limit — this is the entire point:
183
+ * unlike `readBody()`, the caller has opted out of buffering specifically so an arbitrarily large
184
+ * body (e.g. a 20 GB PST/mbox import) never needs to fit in memory at once. Enforcing a size limit,
185
+ * if wanted, is the route handler's own responsibility while consuming the stream.
186
+ *
187
+ * Backpressure is real, not simulated: a chunk that overflows the stream's internal buffer
188
+ * (`push()` returning `false`) calls `uwsRes.pause()` (uWS suspends delivering more `onData`
189
+ * chunks), and the returned `Readable`'s `_read()` calls `uwsRes.resume()` — but ONLY when this
190
+ * function itself previously paused it. uWS's `pause()`/`resume()` are not simple idempotent
191
+ * throttle toggles: empirically, calling `resume()` when the connection was never paused (e.g. from
192
+ * `_read()`'s very first call, before any backpressure has ever been applied) stops any further
193
+ * `onData` delivery for the rest of the request, hanging it forever. A local `paused` flag makes
194
+ * `resume()` a no-op unless a matching `pause()` was actually issued, matching the exact
195
+ * pause/resume pairing uWS expects. A slow consumer (a route piping to a slow disk, or one simply
196
+ * not reading yet) still throttles how fast bytes are pulled off the client connection rather than
197
+ * piling up unbounded data in process memory — the failure mode this whole feature exists to avoid.
198
+ * The same pairing discipline also applies to the final chunk: when a chunk that overflows the
199
+ * buffer also happens to be the last one (`isLast`), `pause()` is skipped entirely — the stream is
200
+ * about to signal EOF via `push(null)` regardless, so Node's `Readable` never calls `_read()` again
201
+ * to issue the matching `resume()`, which would otherwise leave uWS's connection paused forever with
202
+ * nothing left in this function to ever un-pause it.
203
+ *
204
+ * The stream is destroyed with an error if the client disconnects mid-upload (wired through the
205
+ * existing single `onAborted` fan-out via `res.onAbort()`, matching `readBody()`'s own abort
206
+ * handling) so a handler awaiting `for await (const chunk of req.bodyStream)` sees a thrown error
207
+ * instead of hanging forever, and can clean up (e.g. delete a partial temp file) in its own
208
+ * `catch`/`finally`. A route that never reads `req.bodyStream` at all can still hit this path — e.g.
209
+ * `UWSResponse.end()` itself calls `uwsRes.close()` (which fires `onAborted`) when finalizing a
210
+ * response before the body was fully received — so a permanent no-op `error` listener is attached
211
+ * below as well: a `Readable` with no active consumer still emits `error` on `destroy(err)`, and
212
+ * Node treats an `error` event with zero listeners as fatal (crashes the process). This listener
213
+ * doesn't swallow anything from a real consumer — `for await`/`.pipe()`/an explicit `.on("error")`
214
+ * all still see the same event; EventEmitter calls every registered listener, not just the first.
215
+ *
216
+ * Must be called synchronously, before any `await`, in the same tick as request handling begins —
217
+ * uWS requires `onData`/`onAborted` to be registered before any asynchronous operation, matching the
218
+ * exact constraint `readBody()` above is already subject to.
219
+ *
220
+ * @param uwsRes The uWS HttpResponse to read the body from.
221
+ * @param res Used only to register the abort callback through the framework's existing single
222
+ * `onAborted` slot (see `UWSResponse.onAbort()`) — never written to otherwise.
223
+ */
224
+ export declare function makeBodyStream(uwsRes: UWSHttpResponse, res: {
225
+ onAbort: (callback: () => void) => void;
226
+ }): Readable;
@@ -1,5 +1,5 @@
1
1
  import uWS from "uWebSockets.js";
2
- import { type IHttpRouter, type RequestHandler, type WebSocketOptions } from "../types.js";
2
+ import { type HttpRouteOptions, type IHttpRouter, type RequestHandler, type WebSocketOptions } from "../types.js";
3
3
  import { type WsUpgradeAuth } from "../MiddlewareChain.js";
4
4
  export { runChain, extractParamNames } from "../MiddlewareChain.js";
5
5
  export type { WsUpgradeAuth, WsUpgradeAuthResult } from "../MiddlewareChain.js";
@@ -55,13 +55,13 @@ export declare class HttpRouter implements IHttpRouter {
55
55
  private capturePreRouteCount;
56
56
  /** Builds the uWS handler for a route registered at `routePattern` (`undefined` for the router's own fallbacks). */
57
57
  private makeHandler;
58
- get(routePath: string, ...handlers: RequestHandler[]): this;
59
- post(routePath: string, ...handlers: RequestHandler[]): this;
60
- put(routePath: string, ...handlers: RequestHandler[]): this;
61
- delete(routePath: string, ...handlers: RequestHandler[]): this;
62
- patch(routePath: string, ...handlers: RequestHandler[]): this;
63
- head(routePath: string, ...handlers: RequestHandler[]): this;
64
- options(routePath: string, ...handlers: RequestHandler[]): this;
58
+ get(routePath: string, ...handlersOrOptions: Array<RequestHandler | HttpRouteOptions>): this;
59
+ post(routePath: string, ...handlersOrOptions: Array<RequestHandler | HttpRouteOptions>): this;
60
+ put(routePath: string, ...handlersOrOptions: Array<RequestHandler | HttpRouteOptions>): this;
61
+ delete(routePath: string, ...handlersOrOptions: Array<RequestHandler | HttpRouteOptions>): this;
62
+ patch(routePath: string, ...handlersOrOptions: Array<RequestHandler | HttpRouteOptions>): this;
63
+ head(routePath: string, ...handlersOrOptions: Array<RequestHandler | HttpRouteOptions>): this;
64
+ options(routePath: string, ...handlersOrOptions: Array<RequestHandler | HttpRouteOptions>): this;
65
65
  /** Returns `true` if the application has registered its own literal `OPTIONS` route at `path`
66
66
  * (not the framework's own `/*` CORS-preflight fallback). See `explicitOptionsPaths`'s own doc
67
67
  * comment. `path` is normalized the same way registered routes are, so a trailing-slash mismatch
@@ -67,6 +67,8 @@ export declare class ModelUtils {
67
67
  private static idPropertyCache;
68
68
  private static readOnlyPropertyCache;
69
69
  private static columnTypeCache;
70
+ /** Caches each class's `@RequiresScope`-decorated property names to their required scopes (see `assertFieldScope()`). */
71
+ private static scopedPropertyCache;
70
72
  /** Sequence used to give every `Raw()` SQL expression's named parameter a unique name within one query. */
71
73
  private static rawParamSeq;
72
74
  /**
@@ -120,6 +122,43 @@ export declare class ModelUtils {
120
122
  * @returns The list of all property names that have the @ReadOnly decorator applied.
121
123
  */
122
124
  static getReadOnlyPropertyNames(modelClass: any): string[];
125
+ /**
126
+ * Returns a map of every `@RequiresScope`-decorated property on `modelClass` to the scope(s) it requires (the
127
+ * exact same `rrst:scopes` metadata `@rapidrest/core`'s `ObjectUtils.deleteScopedProps()` reads to redact a
128
+ * property from an already-fetched result). `undefined` when `modelClass` isn't provided.
129
+ *
130
+ * Deliberately does NOT use `getReadOnlyPropertyNames()`'s prototype-walking-only pattern: a plain class field
131
+ * initializer (`public salary: number = 0`) compiles to an own-INSTANCE assignment, so it never appears on the
132
+ * prototype at all unless its OWN decorator specifically forces a placeholder there (this framework's
133
+ * `@ReadOnly`/`@Identifier` do; `@rapidrest/core`'s `RequiresScope` has no reason to and doesn't). Confirmed
134
+ * empirically: `Object.getOwnPropertyNames(SomeClass.prototype)` for a bare `@RequiresScope("x") salary = 0`
135
+ * field returns only `["constructor"]`. Instead this walks the property names of an actual constructed
136
+ * instance (mirroring `deleteScopedProps()`'s own `Object.getOwnPropertyNames(obj)` over a data object) union
137
+ * the prototype chain (still needed to also catch a property that IS forced onto the prototype, e.g. one
138
+ * additionally decorated with `@ReadOnly`) — metadata itself is always read off `modelClass.prototype`
139
+ * (`Reflect.getMetadata` walks the prototype chain on its own), regardless of which own-properties surfaced
140
+ * the candidate name.
141
+ */
142
+ private static getScopedPropertyNames;
143
+ /**
144
+ * Rejects (400) a search filter, sort or count referencing a `@RequiresScope`-protected property when the
145
+ * requesting user doesn't hold at least one of its required scopes.
146
+ *
147
+ * Without this, `@RequiresScope` was only ever a RESPONSE-time redaction (`ObjectUtils.deleteScopedProps()`,
148
+ * applied after a query already ran) — a scope-restricted field could still drive `WHERE`/`ORDER BY`/count
149
+ * itself, letting a caller who could never READ the field's value still binary-search it out via repeated
150
+ * `gt()`/`lt()`/`range()` filters, turn a `HEAD` request's `Content-Length` into an existence/equality oracle
151
+ * via `count()`, or read its relative ordering via `sort`. Checked at query-BUILD time instead, so a
152
+ * disallowed field never reaches `WHERE`/`ORDER BY`/count in the first place — matches `hasScopes()`'s exact
153
+ * semantics (same function `deleteScopedProps()` uses), so query-time rejection and response-time redaction
154
+ * never disagree about who has access to a given field.
155
+ *
156
+ * Applied uniformly to every field referenced by a filter (regardless of operator — `eq`/`gt`/`like`/`regex`/
157
+ * `exists`/... all pass through this same per-key check) and to every `sort` key, on both SQL and Mongo.
158
+ *
159
+ * @throws {ApiError} 400 `SEARCH_SCOPED_FIELD` if `property` requires a scope `user` doesn't hold.
160
+ */
161
+ private static assertFieldScope;
123
162
  /**
124
163
  * Resolves the declared type of a model property, from an explicit `type` override on `@Column` or (falling
125
164
  * back) the TypeScript design-time type reflected at decoration time. Returns `undefined` when `modelClass`
@@ -71,6 +71,26 @@ export declare class RouteUtils {
71
71
  * @param requiredScopes The list of scopes of which the authenticated user's token must carry at least one.
72
72
  */
73
73
  checkRequiredScopes(requiredScopes: string[]): RequestHandler;
74
+ /**
75
+ * Creates a WebSocket-specific error-sanitizing middleware, appended as the last item in every
76
+ * `@WebSocket()` route's handler chain (see `registerRoute()`'s WS branch below). Mirrors what
77
+ * `Server.ts`'s `handleError()`/`serializeError()` already do for ordinary HTTP routes: an
78
+ * `ApiError`'s own status/code/message pass through unchanged (it's meant to reach the client),
79
+ * but anything else — a raw DB driver error, an unexpected throw from `RateLimiter`,
80
+ * `ACLUtils.findACL()`, or any other non-`ApiError` thrown by a middleware in the chain — is
81
+ * replaced with a generic internal error before `runChain()`'s own end-of-chain fallback can turn
82
+ * it into a response.
83
+ *
84
+ * This matters specifically for WebSocket routes because `runChain()`'s fallback response, for a
85
+ * WS route, ends up as the literal WebSocket close reason (`ws.close(1002, message)` — see
86
+ * `Router.ts`/`BunRouter.ts`'s `ws()` `open` handlers), visible to any client via the browser
87
+ * close event's `event.reason`. An ordinary HTTP route gets the same sanitization for free
88
+ * because `Server.ts` registers `handleError` as global middleware, which `registerRoute()`
89
+ * concatenates onto every HTTP route's own handler chain — but `app.ws()` is never given
90
+ * `globalMiddleware` (only the WS-specific chain built here), so WS routes need their own copy of
91
+ * the same safety net.
92
+ */
93
+ sanitizeWsError(): RequestHandler;
74
94
  /**
75
95
  * Converts the given array of string or Function objects to functions bound to the given route object.
76
96
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rapidrest/service-core",
3
- "version": "2.1.1",
3
+ "version": "2.2.1",
4
4
  "description": "Provides all core functionality for RapidREST based backend services.",
5
5
  "repository": "https://github.com/rapidrest/service-core.git",
6
6
  "author": "RapidREST <rapidrests@gmail.com>",