@ultimat3/http 22.1.0 → 22.2.0

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.
package/README.md CHANGED
@@ -330,6 +330,39 @@ sign-in redirect, which keys on `X_UNAUTHENTICATED` alone.
330
330
  The sending half is `webhook()` in `@ultimat3/jobs`. Neither package may import the other, so the
331
331
  canonical string is stated in both and pinned by one literal vector asserted in both test files.
332
332
 
333
+ ## The exact body bytes
334
+
335
+ `As of 2026-09-24`. `UltimateRequest#bodyBytes()` returns the body exactly as it was sent,
336
+ whatever its content type. It is size-capped (`bodyLimitBytes`), read once and cached, and it is
337
+ the same byte array that `bodyRaw()` parses. An action handler receives a `Ctx` and not the
338
+ request, so it calls `useRequestBodyBytes()` to read the request in scope:
339
+
340
+ ```ts
341
+ import { action, t } from '@ultimat3/action';
342
+ import { useRequestBodyBytes, useRequestHeader } from '@ultimat3/http';
343
+ import { allow } from '@ultimat3/policy';
344
+
345
+ // The app's own check: SNS signs a canonical string of fields, a gateway signs the bytes.
346
+ declare function verifySender(bytes: Uint8Array, header: string | null, body: string): Promise<void>;
347
+
348
+ export const ingestSesEvent = action({
349
+ input: t.string.max(600_000), // SNS posts text/plain: the decoded body
350
+ output: t.object({ ok: t.boolean }),
351
+ policy: allow('public'), // the signature below authenticates the sender
352
+ async handle({ input }) {
353
+ const bytes = await useRequestBodyBytes(); // what the sender signed, byte for byte
354
+ await verifySender(bytes, useRequestHeader('x-signature'), input);
355
+ return { ok: true };
356
+ },
357
+ });
358
+ ```
359
+
360
+ | | |
361
+ |---|---|
362
+ | why not the decoded string | a decode can rewrite a BOM or an invalid UTF-8 byte, and the signature then fails with no visible cause |
363
+ | one reader | `bodyRaw()` and `bodyBytes()` share one capped read, so an action that reads both reads the stream once |
364
+ | off HTTP | `X_NO_REQUEST`: a job or an MCP call has no body bytes |
365
+
333
366
  ## Errors
334
367
 
335
368
  `X_ROUTE_NOT_FOUND` · `X_METHOD_NOT_ALLOWED` · `X_BODY_INVALID` · `X_UNAUTHENTICATED`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/http",
3
- "version": "22.1.0",
3
+ "version": "22.2.0",
4
4
  "description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,9 +31,9 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "22.1.0",
35
- "@ultimat3/i18n": "22.1.0",
36
- "@ultimat3/schema": "22.1.0",
37
- "@ultimat3/time": "22.1.0"
34
+ "@ultimat3/core": "22.2.0",
35
+ "@ultimat3/i18n": "22.2.0",
36
+ "@ultimat3/schema": "22.2.0",
37
+ "@ultimat3/time": "22.2.0"
38
38
  }
39
39
  }
package/src/context.ts CHANGED
@@ -127,6 +127,13 @@ export interface RequestContext extends Ctx {
127
127
  * "answer 303" can live without a second return protocol.
128
128
  */
129
129
  redirect: RedirectIntent | undefined;
130
+ /**
131
+ * The request body's exact bytes, through `UltimateRequest`'s one capped, cached read — set by
132
+ * its constructor, so there is still one reader and one limit. A slot and not the request: the
133
+ * context is what an action handler can reach (`useRequestBodyBytes()`), and a signature over
134
+ * the body (SNS, a payment gateway) is over bytes a decode would have rewritten.
135
+ */
136
+ readBody: (() => Promise<Uint8Array>) | undefined;
130
137
  response: Response | undefined;
131
138
  error: unknown;
132
139
  }
@@ -228,6 +235,7 @@ export const createRequestContext = (init: RequestContextInit): RequestContext =
228
235
  rateLimit: undefined,
229
236
  cache: undefined,
230
237
  redirect: undefined,
238
+ readBody: undefined,
231
239
  response: undefined,
232
240
  error: undefined,
233
241
  };
@@ -284,6 +292,18 @@ export const useRequestContext = (member = 'the request context'): RequestContex
284
292
  */
285
293
  export const useRequestHeaders = (): Headers => assertInRequest('request headers').requestHeaders;
286
294
 
295
+ /**
296
+ * The exact bytes of the request in scope's body — what a webhook action verifies a signature
297
+ * over. `bodyRaw()` decodes `text/*` to a string, and a decode rewrites a BOM, an invalid byte
298
+ * and nothing else visibly: the signature then fails for a reason no log shows. Same cap, same
299
+ * cache as the parse the action's input came from, so reading both costs one read.
300
+ */
301
+ export const useRequestBodyBytes = (): Promise<Uint8Array> => {
302
+ const read = assertInRequest('request body bytes').readBody;
303
+ if (read === undefined) throw noRequest('request body bytes');
304
+ return read();
305
+ };
306
+
287
307
  export const useRequestHeader = (name: string): string | null => useRequestHeaders().get(name);
288
308
 
289
309
  /** The one way app code reads a cookie the browser sent — a session cookie included. */
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export {
23
23
  asCtx,
24
24
  createRequestContext,
25
25
  elapsedMs,
26
+ useRequestBodyBytes,
26
27
  useRequestContext,
27
28
  useRequestCookie,
28
29
  useRequestHeader,
package/src/request.ts CHANGED
@@ -43,10 +43,14 @@ export class UltimateRequest {
43
43
  readonly raw: Request;
44
44
  readonly ctx: RequestContext;
45
45
  #body: { parsed: unknown } | undefined;
46
+ #bytes: Promise<Uint8Array> | undefined;
46
47
 
47
48
  constructor(raw: Request, ctx: RequestContext) {
48
49
  this.raw = raw;
49
50
  this.ctx = ctx;
51
+ // Published on the context so an action handler — which gets a `Ctx`, never this object —
52
+ // reaches the same cached read (`useRequestBodyBytes()`), not a second reader of the stream.
53
+ ctx.readBody = () => this.bodyBytes();
50
54
  }
51
55
 
52
56
  get method(): string {
@@ -138,6 +142,16 @@ export class UltimateRequest {
138
142
  return parsed;
139
143
  }
140
144
 
145
+ /**
146
+ * The body exactly as sent, whatever its content type: size-capped, read once and cached, and
147
+ * the very bytes `bodyRaw()` parses. Empty for GET/HEAD and for no body. What a signature over
148
+ * the raw body is verified against, and what a signed-upload PUT stores.
149
+ */
150
+ bodyBytes(): Promise<Uint8Array> {
151
+ this.#bytes ??= this.#readBytes();
152
+ return this.#bytes;
153
+ }
154
+
141
155
  async body<Out>(schema: Schema<Out>): Promise<Out> {
142
156
  const outcome = await validate(schema, await this.bodyRaw());
143
157
  if (!outcome.ok) throw bodyInvalid(this.pathname, outcome.issues);
@@ -156,8 +170,8 @@ export class UltimateRequest {
156
170
  throw buildSkew(client, server);
157
171
  }
158
172
 
159
- async #read(): Promise<unknown> {
160
- if (this.method === 'GET' || this.method === 'HEAD') return undefined;
173
+ /** The declared length, refused up front when it is already over the cap. */
174
+ #declaredLength(): number | null {
161
175
  const limit = this.ctx.config.bodyLimitBytes;
162
176
  const header = this.header('content-length');
163
177
  // A missing content-length means "unknown", not "empty" — only an explicit 0 is
@@ -166,6 +180,25 @@ export class UltimateRequest {
166
180
  if (declared !== null && Number.isFinite(declared) && declared > limit) {
167
181
  throw bodyInvalid(this.pathname, [`body is ${declared} bytes, limit is ${limit}`]);
168
182
  }
183
+ return declared;
184
+ }
185
+
186
+ async #readBytes(): Promise<Uint8Array> {
187
+ if (this.method === 'GET' || this.method === 'HEAD') return new Uint8Array(0);
188
+ if (this.#declaredLength() === 0) return new Uint8Array(0);
189
+ // One capped read for every content type, multipart included: the parser runs on bytes this
190
+ // process already agreed to hold, never on a stream it hands to the runtime unbounded.
191
+ const limit = this.ctx.config.bodyLimitBytes;
192
+ const read = await readWithinLimit(this.raw.body, limit);
193
+ if ('over' in read) {
194
+ throw bodyInvalid(this.pathname, [`body is at least ${read.over} bytes, limit is ${limit}`]);
195
+ }
196
+ return read.bytes;
197
+ }
198
+
199
+ async #read(): Promise<unknown> {
200
+ if (this.method === 'GET' || this.method === 'HEAD') return undefined;
201
+ const declared = this.#declaredLength();
169
202
  const type = contentTypeOf(this.raw);
170
203
  // An EMPTY form is a form with no fields, never "no input": a button-only `<form>` posts
171
204
  // `content-length: 0`, and reading that as `undefined` failed every schema with 400.
@@ -182,13 +215,8 @@ export class UltimateRequest {
182
215
  }
183
216
  if (declared === 0) return form ? {} : undefined;
184
217
 
185
- // One capped read for every content type, multipart included: the parser runs on bytes this
186
- // process already agreed to hold, never on a stream it hands to the runtime unbounded.
187
- const read = await readWithinLimit(this.raw.body, limit);
188
- if ('over' in read) {
189
- throw bodyInvalid(this.pathname, [`body is at least ${read.over} bytes, limit is ${limit}`]);
190
- }
191
- if (read.bytes.byteLength === 0) return form ? {} : undefined;
218
+ const bytes = await this.bodyBytes();
219
+ if (bytes.byteLength === 0) return form ? {} : undefined;
192
220
 
193
221
  if (type === 'multipart/form-data') {
194
222
  try {
@@ -196,7 +224,7 @@ export class UltimateRequest {
196
224
  // in — `Response` is the one multipart parser here, exactly as `Request` was.
197
225
  // Copied, not passed through: a `Uint8Array<ArrayBufferLike>` may be backed by a
198
226
  // `SharedArrayBuffer`, which `Response` does not accept.
199
- const form = await new Response(new Uint8Array(read.bytes), {
227
+ const form = await new Response(new Uint8Array(bytes), {
200
228
  headers: { 'content-type': this.raw.headers.get('content-type') ?? type },
201
229
  }).formData();
202
230
  // `collectFields`, never `Object.fromEntries`: a repeated name is a LIST here for the
@@ -213,7 +241,7 @@ export class UltimateRequest {
213
241
  }
214
242
  }
215
243
 
216
- const body = new TextDecoder().decode(read.bytes);
244
+ const body = new TextDecoder().decode(bytes);
217
245
  try {
218
246
  if (type === 'application/json' || type.endsWith('+json')) return JSON.parse(body);
219
247
  if (type === 'application/x-www-form-urlencoded') {