hoa 0.3.5 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## v0.4.0 / 2026-05-09
2
+
3
+ - feat: add SKILL.md
4
+ - fix: improve Content-Length validation in request.length getter
5
+ - fix: normalize Content-Type in response.type getter with trim and lowercase
6
+ - fix: correct HTTP status code validation range from 100-1000 to 100-599
7
+ - fix: return empty string for empty query objects in stringifyQueryToString
8
+ - docs: clarify middleware array flattening behavior in compose function
9
+ - fix: export status code redirect empty mapping constants types
10
+ - fix(request): should clear search when set to null or undefined
11
+ - fix(response): should return null for invalid Content-Length header
12
+ - perf(response): hoist TextEncoder to a module-level singleton
13
+ - perf(request): hoist client IP header list to a module-level constant
14
+ - fix(types): rename HoaError to HttpErrorOptions for naming consistency
15
+ - chore(test): add content-length above 2 GB
16
+
1
17
  ## v0.3.5 / 2026-02-03
2
18
 
3
19
  - hotfix: package.json exports
@@ -17,7 +17,8 @@ const composeSlim = (middlewares) => async (ctx, next) => {
17
17
  };
18
18
  /**
19
19
  * Compose multiple middleware functions into one.
20
- * Validates input, flattens nested arrays, and returns a composed dispatcher.
20
+ * Validates input, flattens one level of nested arrays,
21
+ * and returns a composed dispatcher.
21
22
  *
22
23
  * @param {HoaMiddleware[]|HoaMiddleware[][]} middlewares - Array of middleware functions or nested arrays
23
24
  * @returns {HoaMiddleware} Composed middleware function
@@ -24,6 +24,7 @@ function parseSearchParamsToQuery(searchParams) {
24
24
  */
25
25
  function stringifyQueryToString(query) {
26
26
  if (!query) return "";
27
+ if (Object.keys(query).length === 0) return "";
27
28
  const params = new URLSearchParams();
28
29
  for (const [key, value] of Object.entries(query)) if (Array.isArray(value)) value.forEach((v) => params.append(key, v ?? ""));
29
30
  else params.append(key, value ?? "");
@@ -1,6 +1,21 @@
1
1
  const require_lib_utils = require('./lib/utils.cjs');
2
2
 
3
3
  //#region src/request.js
4
+ const IP_HEADERS = [
5
+ "x-client-ip",
6
+ "x-forwarded-for",
7
+ "cf-connecting-ip",
8
+ "do-connecting-ip",
9
+ "fastly-client-ip",
10
+ "true-client-ip",
11
+ "x-real-ip",
12
+ "x-cluster-client-ip",
13
+ "x-forwarded",
14
+ "forwarded-for",
15
+ "forwarded",
16
+ "x-appengine-user-ip",
17
+ "cf-pseudo-ipv4"
18
+ ];
4
19
  /**
5
20
  * @typedef {Object} ReqJSON
6
21
  * @property {string} method - Request method
@@ -180,7 +195,7 @@ var HoaRequest = class {
180
195
  * @public
181
196
  */
182
197
  set search(val) {
183
- this.url.search = val.startsWith("?") ? val : val ? `?${val}` : "";
198
+ this.url.search = val ?? "";
184
199
  this._query = null;
185
200
  }
186
201
  /**
@@ -396,21 +411,7 @@ var HoaRequest = class {
396
411
  * @public
397
412
  */
398
413
  get ip() {
399
- for (const header of [
400
- "x-client-ip",
401
- "x-forwarded-for",
402
- "cf-connecting-ip",
403
- "do-connecting-ip",
404
- "fastly-client-ip",
405
- "true-client-ip",
406
- "x-real-ip",
407
- "x-cluster-client-ip",
408
- "x-forwarded",
409
- "forwarded-for",
410
- "forwarded",
411
- "x-appengine-user-ip",
412
- "cf-pseudo-ipv4"
413
- ]) {
414
+ for (const header of IP_HEADERS) {
414
415
  const value = this.get(header);
415
416
  if (value) if (header === "x-forwarded-for" || header === "forwarded-for") {
416
417
  const firstIp = value.split(",")[0]?.trim();
@@ -421,7 +422,7 @@ var HoaRequest = class {
421
422
  }
422
423
  /**
423
424
  * Get the request content length from the Content-Length header.
424
- * Returns undefined if the header is missing, empty, or not a valid number.
425
+ * Returns null if the header is missing, empty, or not a valid number.
425
426
  *
426
427
  * @returns {number|null} The content length in bytes, or null if not available
427
428
  * @public
@@ -429,7 +430,10 @@ var HoaRequest = class {
429
430
  get length() {
430
431
  const len = this.get("Content-Length");
431
432
  if (len == null) return null;
432
- return ~~len;
433
+ if (!/^\d+$/.test(len)) return null;
434
+ const n = Number(len);
435
+ if (!Number.isSafeInteger(n)) return null;
436
+ return n;
433
437
  }
434
438
  /**
435
439
  * Get the request content type from the Content-Type header.
@@ -1,6 +1,7 @@
1
1
  const require_lib_utils = require('./lib/utils.cjs');
2
2
 
3
3
  //#region src/response.js
4
+ const encoder = new TextEncoder();
4
5
  /**
5
6
  * @typedef {Object} ResJSON
6
7
  * @property {number} status - Response status code
@@ -142,7 +143,7 @@ var HoaResponse = class {
142
143
  */
143
144
  set status(val) {
144
145
  if (!Number.isInteger(val)) throw new TypeError("status code must be an integer");
145
- if (val < 100 || val > 1e3) throw new TypeError(`invalid status code: ${val}`);
146
+ if (val < 100 || val > 599) throw new TypeError(`invalid status code: ${val}`);
146
147
  this._status = val;
147
148
  this._explicitStatus = true;
148
149
  if (!this._explicitStatusText) this._statusText = require_lib_utils.statusTextMapping[val];
@@ -275,7 +276,7 @@ var HoaResponse = class {
275
276
  get type() {
276
277
  const type = this.get("Content-Type");
277
278
  if (!type) return null;
278
- return type.split(";", 1)[0];
279
+ return type.split(";", 1)[0].trim().toLowerCase();
279
280
  }
280
281
  /**
281
282
  * Set the response Content-Type.
@@ -297,14 +298,20 @@ var HoaResponse = class {
297
298
  * @public
298
299
  */
299
300
  get length() {
300
- if (this.has("Content-Length")) return Number.parseInt(this.get("Content-Length"), 10) || 0;
301
+ const len = this.get("Content-Length");
302
+ if (len != null) {
303
+ if (!/^\d+$/.test(len)) return null;
304
+ const n = Number(len);
305
+ if (!Number.isSafeInteger(n)) return null;
306
+ return n;
307
+ }
301
308
  if (this.body == null || this.body instanceof ReadableStream || this.body instanceof FormData || this.body instanceof Response) return null;
302
- if (typeof this.body === "string") return new TextEncoder().encode(this.body).length;
309
+ if (typeof this.body === "string") return encoder.encode(this.body).length;
303
310
  if (this.body instanceof Blob) return this.body.size;
304
311
  if (this.body instanceof ArrayBuffer) return this.body.byteLength;
305
312
  if (ArrayBuffer.isView(this.body)) return this.body.byteLength;
306
- if (this.body instanceof URLSearchParams) return new TextEncoder().encode(this.body.toString()).length;
307
- return new TextEncoder().encode(JSON.stringify(this.body)).length;
313
+ if (this.body instanceof URLSearchParams) return encoder.encode(this.body.toString()).length;
314
+ return encoder.encode(JSON.stringify(this.body)).length;
308
315
  }
309
316
  /**
310
317
  * Set the response Content-Length header.
@@ -16,7 +16,8 @@ const composeSlim = (middlewares) => async (ctx, next) => {
16
16
  };
17
17
  /**
18
18
  * Compose multiple middleware functions into one.
19
- * Validates input, flattens nested arrays, and returns a composed dispatcher.
19
+ * Validates input, flattens one level of nested arrays,
20
+ * and returns a composed dispatcher.
20
21
  *
21
22
  * @param {HoaMiddleware[]|HoaMiddleware[][]} middlewares - Array of middleware functions or nested arrays
22
23
  * @returns {HoaMiddleware} Composed middleware function
@@ -23,6 +23,7 @@ function parseSearchParamsToQuery(searchParams) {
23
23
  */
24
24
  function stringifyQueryToString(query) {
25
25
  if (!query) return "";
26
+ if (Object.keys(query).length === 0) return "";
26
27
  const params = new URLSearchParams();
27
28
  for (const [key, value] of Object.entries(query)) if (Array.isArray(value)) value.forEach((v) => params.append(key, v ?? ""));
28
29
  else params.append(key, value ?? "");
@@ -1,6 +1,21 @@
1
1
  import { parseSearchParamsToQuery, stringifyQueryToString } from "./lib/utils.mjs";
2
2
 
3
3
  //#region src/request.js
4
+ const IP_HEADERS = [
5
+ "x-client-ip",
6
+ "x-forwarded-for",
7
+ "cf-connecting-ip",
8
+ "do-connecting-ip",
9
+ "fastly-client-ip",
10
+ "true-client-ip",
11
+ "x-real-ip",
12
+ "x-cluster-client-ip",
13
+ "x-forwarded",
14
+ "forwarded-for",
15
+ "forwarded",
16
+ "x-appengine-user-ip",
17
+ "cf-pseudo-ipv4"
18
+ ];
4
19
  /**
5
20
  * @typedef {Object} ReqJSON
6
21
  * @property {string} method - Request method
@@ -180,7 +195,7 @@ var HoaRequest = class {
180
195
  * @public
181
196
  */
182
197
  set search(val) {
183
- this.url.search = val.startsWith("?") ? val : val ? `?${val}` : "";
198
+ this.url.search = val ?? "";
184
199
  this._query = null;
185
200
  }
186
201
  /**
@@ -396,21 +411,7 @@ var HoaRequest = class {
396
411
  * @public
397
412
  */
398
413
  get ip() {
399
- for (const header of [
400
- "x-client-ip",
401
- "x-forwarded-for",
402
- "cf-connecting-ip",
403
- "do-connecting-ip",
404
- "fastly-client-ip",
405
- "true-client-ip",
406
- "x-real-ip",
407
- "x-cluster-client-ip",
408
- "x-forwarded",
409
- "forwarded-for",
410
- "forwarded",
411
- "x-appengine-user-ip",
412
- "cf-pseudo-ipv4"
413
- ]) {
414
+ for (const header of IP_HEADERS) {
414
415
  const value = this.get(header);
415
416
  if (value) if (header === "x-forwarded-for" || header === "forwarded-for") {
416
417
  const firstIp = value.split(",")[0]?.trim();
@@ -421,7 +422,7 @@ var HoaRequest = class {
421
422
  }
422
423
  /**
423
424
  * Get the request content length from the Content-Length header.
424
- * Returns undefined if the header is missing, empty, or not a valid number.
425
+ * Returns null if the header is missing, empty, or not a valid number.
425
426
  *
426
427
  * @returns {number|null} The content length in bytes, or null if not available
427
428
  * @public
@@ -429,7 +430,10 @@ var HoaRequest = class {
429
430
  get length() {
430
431
  const len = this.get("Content-Length");
431
432
  if (len == null) return null;
432
- return ~~len;
433
+ if (!/^\d+$/.test(len)) return null;
434
+ const n = Number(len);
435
+ if (!Number.isSafeInteger(n)) return null;
436
+ return n;
433
437
  }
434
438
  /**
435
439
  * Get the request content type from the Content-Type header.
@@ -1,6 +1,7 @@
1
1
  import { commonTypeMapping, encodeUrl, statusEmptyMapping, statusRedirectMapping, statusTextMapping } from "./lib/utils.mjs";
2
2
 
3
3
  //#region src/response.js
4
+ const encoder = new TextEncoder();
4
5
  /**
5
6
  * @typedef {Object} ResJSON
6
7
  * @property {number} status - Response status code
@@ -142,7 +143,7 @@ var HoaResponse = class {
142
143
  */
143
144
  set status(val) {
144
145
  if (!Number.isInteger(val)) throw new TypeError("status code must be an integer");
145
- if (val < 100 || val > 1e3) throw new TypeError(`invalid status code: ${val}`);
146
+ if (val < 100 || val > 599) throw new TypeError(`invalid status code: ${val}`);
146
147
  this._status = val;
147
148
  this._explicitStatus = true;
148
149
  if (!this._explicitStatusText) this._statusText = statusTextMapping[val];
@@ -275,7 +276,7 @@ var HoaResponse = class {
275
276
  get type() {
276
277
  const type = this.get("Content-Type");
277
278
  if (!type) return null;
278
- return type.split(";", 1)[0];
279
+ return type.split(";", 1)[0].trim().toLowerCase();
279
280
  }
280
281
  /**
281
282
  * Set the response Content-Type.
@@ -297,14 +298,20 @@ var HoaResponse = class {
297
298
  * @public
298
299
  */
299
300
  get length() {
300
- if (this.has("Content-Length")) return Number.parseInt(this.get("Content-Length"), 10) || 0;
301
+ const len = this.get("Content-Length");
302
+ if (len != null) {
303
+ if (!/^\d+$/.test(len)) return null;
304
+ const n = Number(len);
305
+ if (!Number.isSafeInteger(n)) return null;
306
+ return n;
307
+ }
301
308
  if (this.body == null || this.body instanceof ReadableStream || this.body instanceof FormData || this.body instanceof Response) return null;
302
- if (typeof this.body === "string") return new TextEncoder().encode(this.body).length;
309
+ if (typeof this.body === "string") return encoder.encode(this.body).length;
303
310
  if (this.body instanceof Blob) return this.body.size;
304
311
  if (this.body instanceof ArrayBuffer) return this.body.byteLength;
305
312
  if (ArrayBuffer.isView(this.body)) return this.body.byteLength;
306
- if (this.body instanceof URLSearchParams) return new TextEncoder().encode(this.body.toString()).length;
307
- return new TextEncoder().encode(JSON.stringify(this.body)).length;
313
+ if (this.body instanceof URLSearchParams) return encoder.encode(this.body.toString()).length;
314
+ return encoder.encode(JSON.stringify(this.body)).length;
308
315
  }
309
316
  /**
310
317
  * Set the response Content-Length header.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hoa",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "description": "A minimal web framework built on Web Standards",
5
5
  "main": "./dist/cjs/hoa.cjs",
6
6
  "type": "module",
package/types/index.d.ts CHANGED
@@ -1,35 +1,28 @@
1
- interface HoaAppJson {
1
+ export interface HoaAppJson {
2
2
  name: string;
3
3
  }
4
4
 
5
- interface HoaContextJson {
5
+ export interface HoaContextJson {
6
6
  app: HoaAppJson;
7
7
  req: HoaRequestJson;
8
8
  res: HoaResponseJson;
9
9
  }
10
10
 
11
- interface HoaRequestJson {
11
+ export interface HoaRequestJson {
12
12
  method: string;
13
13
  url: string;
14
14
  headers: Record<string, string>;
15
15
  }
16
16
 
17
- interface HoaResponseJson {
17
+ export interface HoaResponseJson {
18
18
  status: number;
19
19
  statusText: string;
20
20
  headers: Record<string, string>;
21
21
  }
22
22
 
23
- interface HoaError {
24
- message?: string;
25
- cause?: unknown;
26
- expose?: boolean;
27
- headers?: Headers | Record<string, string> | Iterable<readonly [string, string]>;
28
- }
29
-
30
23
  export type HoaExtension = (app: Hoa) => void;
31
24
 
32
- export type HoaMiddleware = (ctx: HoaContext, next?: () => Promise<void>) => Promise<void> | void;
25
+ export type HoaMiddleware = (ctx: HoaContext, next: () => Promise<void>) => Promise<void> | void;
33
26
 
34
27
  export declare class Hoa {
35
28
  constructor(options?: { name?: string });
@@ -61,8 +54,10 @@ export declare class HoaContext {
61
54
  env?: any;
62
55
  executionCtx?: any;
63
56
  state: Record<string, any>;
64
- throw(status: number, message?: string | HoaError, options?: HoaError): never;
65
- assert<T>(value: T, status: number, message?: string | HoaError, options?: HoaError): asserts value is NonNullable<T>;
57
+ throw(status: number, message?: string, options?: HttpErrorOptions): never;
58
+ throw(status: number, options: HttpErrorOptions): never;
59
+ assert<T>(value: T, status: number, message?: string, options?: HttpErrorOptions): asserts value is NonNullable<T>;
60
+ assert<T>(value: T, status: number, options: HttpErrorOptions): asserts value is NonNullable<T>;
66
61
  onerror(err: unknown): Response;
67
62
  toJSON(): HoaContextJson;
68
63
  readonly response: Response;
@@ -139,7 +134,7 @@ export declare class HoaRequest {
139
134
  toJSON(): HoaRequestJson;
140
135
  }
141
136
 
142
- type HoaResponseBody =
137
+ export type HoaResponseBody =
143
138
  | string
144
139
  | Blob
145
140
  | ArrayBuffer
@@ -190,12 +185,16 @@ export declare class HoaResponse {
190
185
  toJSON(): HoaResponseJson;
191
186
  }
192
187
 
188
+ export interface HttpErrorOptions {
189
+ message?: string;
190
+ cause?: unknown;
191
+ expose?: boolean;
192
+ headers?: Headers | Record<string, string> | Iterable<readonly [string, string]>;
193
+ }
194
+
193
195
  export declare class HttpError extends Error {
194
- constructor(
195
- status: number,
196
- message?: string | HoaError,
197
- options?: HoaError
198
- );
196
+ constructor(status: number, message?: string, options?: HttpErrorOptions);
197
+ constructor(status: number, options: HttpErrorOptions);
199
198
  readonly name: string;
200
199
  readonly status: number;
201
200
  readonly statusCode: number;
@@ -207,4 +206,10 @@ export declare function compose(
207
206
  middlewares: ReadonlyArray<HoaMiddleware> | ReadonlyArray<ReadonlyArray<HoaMiddleware>>
208
207
  ): HoaMiddleware;
209
208
 
209
+ export const statusTextMapping: Record<number, string>;
210
+
211
+ export const statusRedirectMapping: Record<number, boolean>;
212
+
213
+ export const statusEmptyMapping: Record<number, boolean>;
214
+
210
215
  export default Hoa;