hoa 0.0.1 → 0.1.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.
@@ -0,0 +1,361 @@
1
+ import { statusTextMapping, statusEmptyMapping, statusRedirectMapping, commonTypeMapping, encodeUrl } from "./lib/utils.js";
2
+ class HoaResponse {
3
+ /**
4
+ * Expose a plain object snapshot of response headers.
5
+ * Uses the Web Standard Headers API internally for proper header handling.
6
+ * Returns a plain object representation where header names are normalized.
7
+ *
8
+ * @returns {Record<string, string>} Object with all response headers
9
+ * @public
10
+ */
11
+ get headers() {
12
+ this._headers = this._headers || new Headers();
13
+ return Object.fromEntries(this._headers.entries());
14
+ }
15
+ /**
16
+ * Set the response headers.
17
+ * Accepts either a Headers object or a plain object/array of header entries.
18
+ * This replaces all existing headers with the new ones.
19
+ *
20
+ * @param {Headers|Record<string, string>|Array<[string, string]>} val - Headers to set
21
+ * @public
22
+ */
23
+ set headers(val) {
24
+ if (val instanceof Headers) {
25
+ this._headers = val;
26
+ } else {
27
+ this._headers = new Headers(val);
28
+ }
29
+ }
30
+ /**
31
+ * Get a response header value by name (case-insensitive).
32
+ * Uses the Web Standard Headers API for proper header retrieval.
33
+ * Special-cases "referer/referrer" for compatibility.
34
+ * Returns null for empty or whitespace-only field names.
35
+ *
36
+ * @param {string} field - The header name
37
+ * @returns {string|null} The header value or null if not found
38
+ * @public
39
+ */
40
+ get(field) {
41
+ if (!field) return null;
42
+ this._headers = this._headers || new Headers();
43
+ switch (field = field.toLowerCase()) {
44
+ case "referer":
45
+ case "referrer": {
46
+ return this._headers.get("referrer") ?? this._headers.get("referer");
47
+ }
48
+ default:
49
+ return this._headers.get(field);
50
+ }
51
+ }
52
+ /**
53
+ * Get all Set-Cookie header values as an array.
54
+ * Returns all Set-Cookie headers that will be sent with the response.
55
+ *
56
+ * @returns {string[]} Array of Set-Cookie header values
57
+ * @public
58
+ */
59
+ getSetCookie() {
60
+ this._headers = this._headers || new Headers();
61
+ return this._headers.getSetCookie();
62
+ }
63
+ /**
64
+ * Check if a response header is present.
65
+ * Uses the Web Standard Headers API for case-insensitive header checking.
66
+ *
67
+ * @param {string} field - The header name to check
68
+ * @returns {boolean} True if the header exists
69
+ * @public
70
+ */
71
+ has(field) {
72
+ if (!field) return false;
73
+ this._headers = this._headers || new Headers();
74
+ return this._headers.has(field);
75
+ }
76
+ /**
77
+ * Set response header(s) using the Web Standard Headers API.
78
+ * Accepts various input formats:
79
+ * - Single key/value pair
80
+ * - Plain object with multiple headers
81
+ * Replaces existing header values.
82
+ *
83
+ * @param {string|Record<string,string>} field - Header name or headers object
84
+ * @param {string} [val] - Header value when field is a string
85
+ * @public
86
+ */
87
+ set(field, val) {
88
+ if (!field) return;
89
+ this._headers = this._headers || new Headers();
90
+ if (typeof field === "string") {
91
+ this._headers.set(field, val);
92
+ } else {
93
+ Object.keys(field).forEach((header) => this._headers.set(header, field[header]));
94
+ }
95
+ }
96
+ /**
97
+ * Append a response header value using the Web Standard Headers API.
98
+ * Does not replace existing values, but appends to them.
99
+ * This is useful for headers that can have multiple values like Set-Cookie.
100
+ *
101
+ * @param {string|Record<string,string>} field - The header name or headers object
102
+ * @param {string} [val] - The value to append when field is a string
103
+ * @public
104
+ */
105
+ append(field, val) {
106
+ if (!field) return;
107
+ this._headers = this._headers || new Headers();
108
+ if (typeof field === "string") {
109
+ this._headers.append(field, val);
110
+ } else {
111
+ Object.keys(field).forEach((header) => this._headers.append(header, field[header]));
112
+ }
113
+ }
114
+ /**
115
+ * Delete a response header by name using the Web Standard Headers API.
116
+ * Header deletion is case-insensitive.
117
+ *
118
+ * @param {string} field - The header name to delete
119
+ * @public
120
+ */
121
+ delete(field) {
122
+ if (!field) return;
123
+ this._headers = this._headers || new Headers();
124
+ this._headers.delete(field);
125
+ }
126
+ /**
127
+ * Get the response status code.
128
+ * Defaults to 200 if not explicitly set.
129
+ *
130
+ * @returns {number} The HTTP status code
131
+ * @public
132
+ */
133
+ get status() {
134
+ return this._status || 200;
135
+ }
136
+ /**
137
+ * Set the response status code.
138
+ * Automatically clears body for status codes that should not have content.
139
+ *
140
+ * @param {number} val - The HTTP status code (100-599)
141
+ * @throws {TypeError} When status code is not an integer
142
+ * @throws {TypeError} When status code is out of valid range (100–599)
143
+ * @public
144
+ */
145
+ set status(val) {
146
+ if (!Number.isInteger(val)) {
147
+ throw new TypeError("status code must be an integer");
148
+ }
149
+ if (val < 100 || val > 1e3) {
150
+ throw new TypeError(`invalid status code: ${val}`);
151
+ }
152
+ this._status = val;
153
+ this._explicitStatus = true;
154
+ if (!this._explicitStatusText) {
155
+ this._statusText = statusTextMapping[val];
156
+ }
157
+ if (this.body && statusEmptyMapping[val]) this.body = null;
158
+ }
159
+ /**
160
+ * Get the response status text.
161
+ *
162
+ * @returns {string} The statusText (e.g., 'OK', 'Not Found')
163
+ * @public
164
+ */
165
+ get statusText() {
166
+ if (this._explicitStatusText) {
167
+ return this._statusText;
168
+ }
169
+ return this._statusText || (this._statusText = statusTextMapping[this.status]);
170
+ }
171
+ /**
172
+ * Set a custom response status text.
173
+ *
174
+ * @param {string} val - The custom status text
175
+ * @public
176
+ */
177
+ set statusText(val) {
178
+ this._statusText = val;
179
+ this._explicitStatusText = true;
180
+ }
181
+ /**
182
+ * Get the response body.
183
+ *
184
+ * @returns {any} The response body content
185
+ * @public
186
+ */
187
+ get body() {
188
+ return this._body;
189
+ }
190
+ /**
191
+ * Set response body with automatic content-type detection.
192
+ * Supports various body types and automatically sets appropriate headers.
193
+ * When set to null/undefined, if current Content-Type is application/json,
194
+ * body becomes the literal string 'null'; otherwise status is set to 204 and
195
+ * Content-Type/Transfer-Encoding are removed.
196
+ *
197
+ * @param {string|Object|ReadableStream|Blob|Response|ArrayBuffer|TypedArray|FormData|URLSearchParams|null} val - The response body
198
+ * @public
199
+ */
200
+ set body(val) {
201
+ this._body = val;
202
+ if (val == null) {
203
+ if (!statusEmptyMapping[this.status]) {
204
+ if (this.type === "application/json") {
205
+ this._body = "null";
206
+ return;
207
+ }
208
+ this.status = 204;
209
+ }
210
+ if (val === null) this._explicitNullBody = true;
211
+ this.delete("Content-Type");
212
+ this.delete("Content-Length");
213
+ this.delete("Transfer-Encoding");
214
+ return;
215
+ }
216
+ if (!this._explicitStatus) this.status = 200;
217
+ const noType = !this.has("Content-Type");
218
+ if (typeof val === "string") {
219
+ if (noType) this.type = /^\s*</.test(val) ? "html" : "text";
220
+ return;
221
+ }
222
+ if (val instanceof Blob || val instanceof ArrayBuffer || ArrayBuffer.isView(val) || val instanceof ReadableStream) {
223
+ if (noType) {
224
+ if (val instanceof Blob && val.type) {
225
+ this.set("Content-Type", val.type);
226
+ } else {
227
+ this.type = "bin";
228
+ }
229
+ }
230
+ return;
231
+ }
232
+ if (val instanceof FormData) {
233
+ return;
234
+ }
235
+ if (val instanceof URLSearchParams) {
236
+ if (noType) this.type = "form";
237
+ return;
238
+ }
239
+ if (val instanceof Response) {
240
+ if (noType) this.type = "bin";
241
+ this.status = val.status;
242
+ for (const [k, v] of val.headers) this.set(k, v);
243
+ return;
244
+ }
245
+ if (!this.type || !/\bjson\b/i.test(this.type)) this.type = "json";
246
+ }
247
+ /**
248
+ * Perform an HTTP redirect to the specified URL.
249
+ * Automatically sets the Location header and appropriate status code.
250
+ * Absolute URLs are normalized and Location value is URL-encoded.
251
+ *
252
+ * @param {string} url - The URL to redirect to (absolute or relative)
253
+ * @public
254
+ */
255
+ redirect(url) {
256
+ if (/^https?:\/\//i.test(url)) {
257
+ url = new URL(url).toString();
258
+ }
259
+ this.set("Location", encodeUrl(url));
260
+ if (!statusRedirectMapping[this.status]) this.status = 302;
261
+ this.type = "text";
262
+ this.body = `Redirecting to ${url}.`;
263
+ }
264
+ /**
265
+ * Perform a special-cased "back" redirect using the Referrer header.
266
+ * When Referrer is not present or unsafe, falls back to the provided alternative or "/".
267
+ * Only redirects to same-origin referrers for security.
268
+ * If referrer is an absolute URL with different origin or an invalid URL, falls back.
269
+ *
270
+ * @param {string} [alt='/'] - Alternative URL when referrer is unavailable or unsafe
271
+ * @public
272
+ */
273
+ back(alt) {
274
+ const referrer = this.req.get("Referrer");
275
+ if (referrer) {
276
+ if (referrer.startsWith("/")) {
277
+ this.redirect(referrer);
278
+ return;
279
+ }
280
+ const url = new URL(referrer, this.req.href);
281
+ if (url.origin === this.req.origin) {
282
+ this.redirect(referrer);
283
+ return;
284
+ }
285
+ }
286
+ this.redirect(alt || "/");
287
+ }
288
+ /**
289
+ * Get the response Content-Type without parameters.
290
+ * Returns only the media type without parameters (e.g., 'application/json').
291
+ *
292
+ * @returns {string|null} The content type, or null if not set
293
+ * @public
294
+ */
295
+ get type() {
296
+ const type = this.get("Content-Type");
297
+ if (!type) return null;
298
+ return type.split(";", 1)[0];
299
+ }
300
+ /**
301
+ * Set the response Content-Type.
302
+ * Supports both full MIME types and shorthand aliases.
303
+ *
304
+ * @param {string} type - The content type or alias (e.g., 'json', 'html', 'application/json')
305
+ * @public
306
+ */
307
+ set type(type) {
308
+ if (!type) return;
309
+ type = commonTypeMapping[type] || type;
310
+ this.set("Content-Type", type);
311
+ }
312
+ /**
313
+ * Get the response Content-Length as a number.
314
+ * Returns the parsed Content-Length header value, or calculates it from the body if available.
315
+ *
316
+ * @returns {number|null} The content length in bytes, or null if not determinable
317
+ * @public
318
+ */
319
+ get length() {
320
+ if (this.has("Content-Length")) {
321
+ return Number.parseInt(this.get("Content-Length"), 10) || 0;
322
+ }
323
+ if (this.body == null || this.body instanceof ReadableStream || this.body instanceof FormData || this.body instanceof Response) {
324
+ return null;
325
+ }
326
+ if (typeof this.body === "string") return new TextEncoder().encode(this.body).length;
327
+ if (this.body instanceof Blob) return this.body.size;
328
+ if (this.body instanceof ArrayBuffer) return this.body.byteLength;
329
+ if (ArrayBuffer.isView(this.body)) return this.body.byteLength;
330
+ if (this.body instanceof URLSearchParams) return new TextEncoder().encode(this.body.toString()).length;
331
+ return new TextEncoder().encode(JSON.stringify(this.body)).length;
332
+ }
333
+ /**
334
+ * Set the response Content-Length header.
335
+ * Only sets the header if Transfer-Encoding is not present.
336
+ *
337
+ * @param {number|string} val - The content length in bytes
338
+ * @public
339
+ */
340
+ set length(val) {
341
+ if (!this.has("Transfer-Encoding")) {
342
+ this.set("Content-Length", val);
343
+ }
344
+ }
345
+ /**
346
+ * Return JSON representation of the response.
347
+ *
348
+ * @returns {ResJSON}
349
+ * @public
350
+ */
351
+ toJSON() {
352
+ return {
353
+ status: this.status,
354
+ statusText: this.statusText,
355
+ headers: this.headers
356
+ };
357
+ }
358
+ }
359
+ export {
360
+ HoaResponse as default
361
+ };
package/package.json CHANGED
@@ -1,12 +1,67 @@
1
1
  {
2
2
  "name": "hoa",
3
- "version": "0.0.1",
4
- "description": "",
5
- "main": "index.js",
3
+ "version": "0.1.1",
4
+ "description": "A minimal web framework built on Web Standards",
5
+ "main": "./dist/cjs/application.js",
6
+ "type": "module",
7
+ "module": "./dist/esm/application.js",
8
+ "types": "./types/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./types/index.d.ts",
12
+ "import": "./dist/esm/application.js",
13
+ "require": "./dist/cjs/application.js",
14
+ "default": "./dist/esm/application.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "types",
20
+ "CHANGELOG.md"
21
+ ],
6
22
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
23
+ "lint": "eslint .",
24
+ "docs:dev": "vitepress dev",
25
+ "docs:build": "vitepress build",
26
+ "docs:preview": "vitepress preview",
27
+ "build": "tsup",
28
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
29
+ "prepublishOnly": "npm run lint && npm run test && npm run build",
30
+ "prepare": "husky"
31
+ },
32
+ "author": "nswbmw",
33
+ "license": "MIT",
34
+ "homepage": "https://hoa-js.com",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/hoa-js/hoa.git"
38
+ },
39
+ "keywords": [
40
+ "web",
41
+ "app",
42
+ "http",
43
+ "application",
44
+ "framework",
45
+ "middleware",
46
+ "nodejs",
47
+ "deno",
48
+ "bun",
49
+ "cloudflare",
50
+ "serverless",
51
+ "lambda"
52
+ ],
53
+ "devDependencies": {
54
+ "@commitlint/cli": "20.1.0",
55
+ "@commitlint/config-conventional": "20.0.0",
56
+ "eslint": "9.37.0",
57
+ "globals": "16.4.0",
58
+ "husky": "9.1.7",
59
+ "jest": "30.2.0",
60
+ "neostandard": "0.12.2",
61
+ "tsup": "8.5.0",
62
+ "vitepress": "1.6.4"
8
63
  },
9
- "keywords": [],
10
- "author": "",
11
- "license": "ISC"
64
+ "engines": {
65
+ "node": ">=20"
66
+ }
12
67
  }
@@ -0,0 +1,233 @@
1
+ export type NextFunction = () => Promise<void>;
2
+
3
+ export interface ApplicationOptions {
4
+ name?: string;
5
+ }
6
+
7
+ export type HeaderEntry = readonly [string, string];
8
+
9
+ export type HoaHeadersInit = Headers | Record<string, string> | Iterable<HeaderEntry>;
10
+
11
+ export interface AppJSON {
12
+ name: string;
13
+ }
14
+
15
+ export interface ReqJSON {
16
+ method: string;
17
+ url: string;
18
+ headers: Record<string, string>;
19
+ }
20
+
21
+ export interface ResJSON {
22
+ status: number;
23
+ statusText: string;
24
+ headers: Record<string, string>;
25
+ }
26
+
27
+ export interface CtxJSON {
28
+ app: AppJSON;
29
+ req: ReqJSON;
30
+ res: ResJSON;
31
+ }
32
+
33
+ export interface CtxOptions {
34
+ request?: Request;
35
+ env?: any;
36
+ executionCtx?: any;
37
+ }
38
+
39
+ export interface HttpErrorOptions {
40
+ message?: string;
41
+ cause?: unknown;
42
+ expose?: boolean;
43
+ headers?: HoaHeadersInit;
44
+ }
45
+
46
+ export type HoaMiddleware<Ctx extends HoaContext = HoaContext> = (ctx: Ctx, next: NextFunction) => Promise<any> | any;
47
+
48
+ export declare class HttpError extends Error {
49
+ constructor(status: number, message?: string | HttpErrorOptions, options?: HttpErrorOptions);
50
+ readonly status: number;
51
+ readonly statusCode: number;
52
+ readonly expose: boolean;
53
+ readonly headers?: Record<string, string>;
54
+ }
55
+
56
+ export declare class HoaContext {
57
+ constructor(options?: CtxOptions);
58
+ app: Application;
59
+ req: HoaRequest;
60
+ res: HoaResponse;
61
+ request?: Request;
62
+ env?: any;
63
+ executionCtx?: any;
64
+ state: Record<string, any>;
65
+ throw(status: number, message?: string | HttpErrorOptions, options?: HttpErrorOptions): never;
66
+ assert<T>(value: T, status: number, message?: string | HttpErrorOptions, options?: HttpErrorOptions): asserts value;
67
+ onerror(err: unknown): Response;
68
+ toJSON(): CtxJSON;
69
+ }
70
+
71
+ export declare class HoaRequest {
72
+ app: Application;
73
+ ctx: HoaContext;
74
+ res: HoaResponse;
75
+
76
+ get url(): URL;
77
+ set url(value: string | URL);
78
+
79
+ get href(): string;
80
+ set href(value: string);
81
+
82
+ get origin(): string;
83
+ set origin(value: string);
84
+
85
+ get protocol(): string;
86
+ set protocol(value: string);
87
+
88
+ get host(): string;
89
+ set host(value: string);
90
+
91
+ get hostname(): string;
92
+ set hostname(value: string);
93
+
94
+ get port(): string;
95
+ set port(value: string);
96
+
97
+ get pathname(): string;
98
+ set pathname(value: string);
99
+
100
+ get search(): string;
101
+ set search(value: string);
102
+
103
+ get hash(): string;
104
+ set hash(value: string);
105
+
106
+ get method(): string;
107
+ set method(value: string);
108
+
109
+ get query(): Record<string, string | string[]>;
110
+ set query(value: Record<string, string | readonly string[]>);
111
+
112
+ get headers(): Record<string, string>;
113
+ set headers(value: HoaHeadersInit);
114
+
115
+ get body(): ReadableStream<Uint8Array> | null;
116
+ set body(value: any);
117
+
118
+ get(field: string): string | null;
119
+ getSetCookie(): string[];
120
+ has(field: string): boolean;
121
+ set(field: string, value: string): void;
122
+ set(values: Record<string, string>): void;
123
+ append(field: string, value: string): void;
124
+ append(values: Record<string, string>): void;
125
+ delete(field: string): void;
126
+
127
+ get ips(): string[];
128
+ get ip(): string;
129
+
130
+ get length(): number | null;
131
+
132
+ get type(): string | null;
133
+
134
+ blob(): Promise<Blob>;
135
+ arrayBuffer(): Promise<ArrayBuffer>;
136
+ text(): Promise<string>;
137
+ json<T = any>(): Promise<T>;
138
+ formData(): Promise<FormData>;
139
+ toJSON(): ReqJSON;
140
+ }
141
+
142
+ export type ResponseBody =
143
+ | string
144
+ | Blob
145
+ | ArrayBuffer
146
+ | ArrayBufferView
147
+ | ReadableStream<any>
148
+ | FormData
149
+ | URLSearchParams
150
+ | Response
151
+ | Record<string, any>
152
+ | null
153
+ | undefined;
154
+
155
+ export declare class HoaResponse {
156
+ app: Application;
157
+ ctx: HoaContext;
158
+ req: HoaRequest;
159
+
160
+ get headers(): Record<string, string>;
161
+ set headers(value: HoaHeadersInit);
162
+
163
+ get(field: string): string | null;
164
+ getSetCookie(): string[];
165
+ has(field: string): boolean;
166
+ set(field: string, value: string): void;
167
+ set(values: Record<string, string>): void;
168
+ append(field: string, value: string): void;
169
+ append(values: Record<string, string>): void;
170
+ delete(field: string): void;
171
+
172
+ get status(): number;
173
+ set status(value: number);
174
+
175
+ get statusText(): string;
176
+ set statusText(value: string);
177
+
178
+ get body(): ResponseBody;
179
+ set body(value: ResponseBody);
180
+
181
+ redirect(url: string): void;
182
+ back(alt?: string): void;
183
+
184
+ get type(): string | null;
185
+ set type(value: string);
186
+
187
+ get length(): number | null;
188
+ set length(value: number | string);
189
+
190
+ toJSON(): ResJSON;
191
+ }
192
+
193
+ export declare class Application {
194
+ constructor(options?: ApplicationOptions);
195
+
196
+ name: string;
197
+ readonly HoaContext: typeof HoaContext;
198
+ readonly HoaRequest: typeof HoaRequest;
199
+ readonly HoaResponse: typeof HoaResponse;
200
+ readonly middlewares: HoaMiddleware[];
201
+
202
+ extend(fn: (app: Application) => void): this;
203
+ use(fn: HoaMiddleware): this;
204
+ fetch(request: Request, env?: any, executionCtx?: any): Promise<Response>;
205
+ protected handleRequest(ctx: HoaContext, middlewareFn: (ctx: HoaContext) => Promise<void>): Promise<Response>;
206
+ protected createContext(request: Request, env?: any, executionCtx?: any): HoaContext;
207
+ protected onerror(err: unknown, ctx?: HoaContext): void;
208
+ toJSON(): AppJSON;
209
+
210
+ static get default(): typeof Application;
211
+ }
212
+
213
+ export declare function compose<Ctx extends HoaContext = HoaContext>(
214
+ middlewares: ReadonlyArray<HoaMiddleware<Ctx>> | ReadonlyArray<ReadonlyArray<HoaMiddleware<Ctx>>>
215
+ ): (ctx: Ctx, next?: NextFunction) => Promise<void>;
216
+
217
+ export {
218
+ ApplicationOptions,
219
+ AppJSON,
220
+ CtxJSON,
221
+ CtxOptions,
222
+ HoaHeadersInit,
223
+ HoaMiddleware,
224
+ HttpErrorOptions,
225
+ ReqJSON,
226
+ ResJSON,
227
+ NextFunction,
228
+ ResponseBody
229
+ };
230
+
231
+ export { Application as Hoa, HoaContext, HoaRequest, HoaResponse, HttpError, compose };
232
+
233
+ export default Application;