hoa 0.3.3 → 0.3.4

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,335 @@
1
+ import { commonTypeMapping, encodeUrl, statusEmptyMapping, statusRedirectMapping, statusTextMapping } from "./lib/utils.mjs";
2
+
3
+ //#region src/response.js
4
+ /**
5
+ * @typedef {Object} ResJSON
6
+ * @property {number} status - Response status code
7
+ * @property {string} statusText - Response status text
8
+ * @property {Record<string, string|string[]>} headers - Response headers
9
+ */
10
+ /**
11
+ * @class HoaResponse
12
+ */
13
+ var HoaResponse = class {
14
+ /**
15
+ * Expose a plain object snapshot of response headers.
16
+ * Uses the Web Standard Headers API internally for proper header handling.
17
+ * Returns a plain object representation where header names are normalized.
18
+ *
19
+ * @returns {Record<string, string>} Object with all response headers
20
+ * @public
21
+ */
22
+ get headers() {
23
+ this._headers = this._headers || new Headers();
24
+ return Object.fromEntries(this._headers.entries());
25
+ }
26
+ /**
27
+ * Set the response headers.
28
+ * Accepts either a Headers object or a plain object/array of header entries.
29
+ * This replaces all existing headers with the new ones.
30
+ *
31
+ * @param {Headers|Record<string, string>|Array<[string, string]>} val - Headers to set
32
+ * @public
33
+ */
34
+ set headers(val) {
35
+ if (val instanceof Headers) this._headers = val;
36
+ else this._headers = new Headers(val);
37
+ }
38
+ /**
39
+ * Get a response header value by name (case-insensitive).
40
+ * Uses the Web Standard Headers API for proper header retrieval.
41
+ * Special-cases "referer/referrer" for compatibility.
42
+ * Returns null for empty or whitespace-only field names.
43
+ *
44
+ * @param {string} field - The header name
45
+ * @returns {string|null} The header value or null if not found
46
+ * @public
47
+ */
48
+ get(field) {
49
+ if (!field) return null;
50
+ this._headers = this._headers || new Headers();
51
+ switch (field = field.toLowerCase()) {
52
+ case "referer":
53
+ case "referrer": return this._headers.get("referrer") ?? this._headers.get("referer");
54
+ default: return this._headers.get(field);
55
+ }
56
+ }
57
+ /**
58
+ * Get all Set-Cookie header values as an array.
59
+ * Returns all Set-Cookie headers that will be sent with the response.
60
+ *
61
+ * @returns {string[]} Array of Set-Cookie header values
62
+ * @public
63
+ */
64
+ getSetCookie() {
65
+ this._headers = this._headers || new Headers();
66
+ return this._headers.getSetCookie();
67
+ }
68
+ /**
69
+ * Check if a response header is present.
70
+ * Uses the Web Standard Headers API for case-insensitive header checking.
71
+ *
72
+ * @param {string} field - The header name to check
73
+ * @returns {boolean} True if the header exists
74
+ * @public
75
+ */
76
+ has(field) {
77
+ if (!field) return false;
78
+ this._headers = this._headers || new Headers();
79
+ return this._headers.has(field);
80
+ }
81
+ /**
82
+ * Set response header(s) using the Web Standard Headers API.
83
+ * Accepts various input formats:
84
+ * - Single key/value pair
85
+ * - Plain object with multiple headers
86
+ * Replaces existing header values.
87
+ *
88
+ * @param {string|Record<string,string>} field - Header name or headers object
89
+ * @param {string} [val] - Header value when field is a string
90
+ * @public
91
+ */
92
+ set(field, val) {
93
+ if (!field) return;
94
+ this._headers = this._headers || new Headers();
95
+ if (typeof field === "string") this._headers.set(field, val);
96
+ else Object.keys(field).forEach((header) => this._headers.set(header, field[header]));
97
+ }
98
+ /**
99
+ * Append a response header value using the Web Standard Headers API.
100
+ * Does not replace existing values, but appends to them.
101
+ * This is useful for headers that can have multiple values like Set-Cookie.
102
+ *
103
+ * @param {string|Record<string,string>} field - The header name or headers object
104
+ * @param {string} [val] - The value to append when field is a string
105
+ * @public
106
+ */
107
+ append(field, val) {
108
+ if (!field) return;
109
+ this._headers = this._headers || new Headers();
110
+ if (typeof field === "string") this._headers.append(field, val);
111
+ else Object.keys(field).forEach((header) => this._headers.append(header, field[header]));
112
+ }
113
+ /**
114
+ * Delete a response header by name using the Web Standard Headers API.
115
+ * Header deletion is case-insensitive.
116
+ *
117
+ * @param {string} field - The header name to delete
118
+ * @public
119
+ */
120
+ delete(field) {
121
+ if (!field) return;
122
+ this._headers = this._headers || new Headers();
123
+ this._headers.delete(field);
124
+ }
125
+ /**
126
+ * Get the response status code.
127
+ * Defaults to 404 if not explicitly set.
128
+ *
129
+ * @returns {number} The HTTP status code
130
+ * @public
131
+ */
132
+ get status() {
133
+ return this._status || 404;
134
+ }
135
+ /**
136
+ * Set the response status code.
137
+ * Automatically clears body for status codes that should not have content.
138
+ *
139
+ * @param {number} val - The HTTP status code (100-599)
140
+ * @throws {TypeError}
141
+ * @public
142
+ */
143
+ set status(val) {
144
+ 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
+ this._status = val;
147
+ this._explicitStatus = true;
148
+ if (!this._explicitStatusText) this._statusText = statusTextMapping[val];
149
+ if (this.body && statusEmptyMapping[val]) this.body = null;
150
+ }
151
+ /**
152
+ * Get the response status text.
153
+ *
154
+ * @returns {string} The statusText (e.g., 'OK', 'Not Found')
155
+ * @public
156
+ */
157
+ get statusText() {
158
+ if (this._explicitStatusText) return this._statusText;
159
+ return this._statusText || (this._statusText = statusTextMapping[this.status]);
160
+ }
161
+ /**
162
+ * Set a custom response status text.
163
+ *
164
+ * @param {string} val - The custom status text
165
+ * @public
166
+ */
167
+ set statusText(val) {
168
+ this._statusText = val;
169
+ this._explicitStatusText = true;
170
+ }
171
+ /**
172
+ * Get the response body.
173
+ *
174
+ * @returns {any} The response body content
175
+ * @public
176
+ */
177
+ get body() {
178
+ return this._body;
179
+ }
180
+ /**
181
+ * Set response body with automatic content-type detection.
182
+ * Supports various body types and automatically sets appropriate headers.
183
+ * When set to null/undefined, if current Content-Type is application/json,
184
+ * body becomes the literal string 'null'; otherwise status is set to 204 and
185
+ * Content-Type/Transfer-Encoding are removed.
186
+ *
187
+ * @param {string|Object|ReadableStream|Blob|Response|ArrayBuffer|TypedArray|FormData|URLSearchParams|null} val - The response body
188
+ * @public
189
+ */
190
+ set body(val) {
191
+ this._body = val;
192
+ if (val == null) {
193
+ if (!statusEmptyMapping[this.status]) {
194
+ if (this.type === "application/json") {
195
+ this._body = "null";
196
+ return;
197
+ }
198
+ this.status = 204;
199
+ }
200
+ if (val === null) this._explicitNullBody = true;
201
+ this.delete("Content-Type");
202
+ this.delete("Content-Length");
203
+ this.delete("Transfer-Encoding");
204
+ return;
205
+ }
206
+ if (!this._explicitStatus) this.status = 200;
207
+ const noType = !this.has("Content-Type");
208
+ if (typeof val === "string") {
209
+ if (noType) this.type = /^\s*</.test(val) ? "html" : "text";
210
+ return;
211
+ }
212
+ if (val instanceof Blob || val instanceof ArrayBuffer || ArrayBuffer.isView(val) || val instanceof ReadableStream) {
213
+ if (noType) if (val instanceof Blob && val.type) this.set("Content-Type", val.type);
214
+ else this.type = "bin";
215
+ return;
216
+ }
217
+ if (val instanceof FormData) return;
218
+ if (val instanceof URLSearchParams) {
219
+ if (noType) this.type = "form";
220
+ return;
221
+ }
222
+ if (val instanceof Response) {
223
+ if (noType) this.type = "bin";
224
+ this.status = val.status;
225
+ for (const [k, v] of val.headers) this.set(k, v);
226
+ return;
227
+ }
228
+ if (!this.type || !/\bjson\b/i.test(this.type)) this.type = "json";
229
+ }
230
+ /**
231
+ * Perform an HTTP redirect to the specified URL.
232
+ * Automatically sets the Location header and appropriate status code.
233
+ * Absolute URLs are normalized and Location value is URL-encoded.
234
+ *
235
+ * @param {string} url - The URL to redirect to (absolute or relative)
236
+ * @public
237
+ */
238
+ redirect(url) {
239
+ if (/^https?:\/\//i.test(url)) url = new URL(url).toString();
240
+ this.set("Location", encodeUrl(url));
241
+ if (!statusRedirectMapping[this.status]) this.status = 302;
242
+ this.type = "text";
243
+ this.body = `Redirecting to ${url}.`;
244
+ }
245
+ /**
246
+ * Perform a special-cased "back" redirect using the Referrer header.
247
+ * When Referrer is not present or unsafe, falls back to the provided alternative or "/".
248
+ * Only redirects to same-origin referrers for security.
249
+ * If referrer is an absolute URL with different origin or an invalid URL, falls back.
250
+ *
251
+ * @param {string} [alt='/'] - Alternative URL when referrer is unavailable or unsafe
252
+ * @public
253
+ */
254
+ back(alt) {
255
+ const referrer = this.req.get("Referrer");
256
+ if (referrer) {
257
+ if (referrer.startsWith("/")) {
258
+ this.redirect(referrer);
259
+ return;
260
+ }
261
+ if (new URL(referrer, this.req.href).origin === this.req.origin) {
262
+ this.redirect(referrer);
263
+ return;
264
+ }
265
+ }
266
+ this.redirect(alt || "/");
267
+ }
268
+ /**
269
+ * Get the response Content-Type without parameters.
270
+ * Returns only the media type without parameters (e.g., 'application/json').
271
+ *
272
+ * @returns {string|null} The content type, or null if not set
273
+ * @public
274
+ */
275
+ get type() {
276
+ const type = this.get("Content-Type");
277
+ if (!type) return null;
278
+ return type.split(";", 1)[0];
279
+ }
280
+ /**
281
+ * Set the response Content-Type.
282
+ * Supports both full MIME types and shorthand aliases.
283
+ *
284
+ * @param {string} type - The content type or alias (e.g., 'json', 'html', 'application/json')
285
+ * @public
286
+ */
287
+ set type(val) {
288
+ if (!val) return;
289
+ val = commonTypeMapping[val] || val;
290
+ this.set("Content-Type", val);
291
+ }
292
+ /**
293
+ * Get the response Content-Length as a number.
294
+ * Returns the parsed Content-Length header value, or calculates it from the body if available.
295
+ *
296
+ * @returns {number|null} The content length in bytes, or null if not determinable
297
+ * @public
298
+ */
299
+ get length() {
300
+ if (this.has("Content-Length")) return Number.parseInt(this.get("Content-Length"), 10) || 0;
301
+ 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;
303
+ if (this.body instanceof Blob) return this.body.size;
304
+ if (this.body instanceof ArrayBuffer) return this.body.byteLength;
305
+ 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;
308
+ }
309
+ /**
310
+ * Set the response Content-Length header.
311
+ * Only sets the header if Transfer-Encoding is not present.
312
+ *
313
+ * @param {number|string} val - The content length in bytes
314
+ * @public
315
+ */
316
+ set length(val) {
317
+ if (!this.has("Transfer-Encoding")) this.set("Content-Length", val);
318
+ }
319
+ /**
320
+ * Return JSON representation of the response.
321
+ *
322
+ * @returns {ResJSON} JSON representation of response
323
+ * @public
324
+ */
325
+ toJSON() {
326
+ return {
327
+ status: this.status,
328
+ statusText: this.statusText,
329
+ headers: this.headers
330
+ };
331
+ }
332
+ };
333
+
334
+ //#endregion
335
+ export { HoaResponse as default };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hoa",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "A minimal web framework built on Web Standards",
5
5
  "main": "./dist/cjs/hoa.js",
6
6
  "type": "module",
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "scripts": {
23
23
  "lint": "eslint .",
24
- "build": "tsup",
24
+ "build": "tsdown",
25
25
  "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
26
26
  "prepublishOnly": "npm run lint && npm run test && npm run build",
27
27
  "prepare": "husky"
@@ -48,14 +48,14 @@
48
48
  "lambda"
49
49
  ],
50
50
  "devDependencies": {
51
- "@commitlint/cli": "20.1.0",
52
- "@commitlint/config-conventional": "20.0.0",
53
- "eslint": "9.39.1",
54
- "globals": "16.5.0",
51
+ "@commitlint/cli": "20.3.1",
52
+ "@commitlint/config-conventional": "20.3.1",
53
+ "eslint": "9.39.2",
54
+ "globals": "17.1.0",
55
55
  "husky": "9.1.7",
56
56
  "jest": "30.2.0",
57
57
  "neostandard": "0.12.2",
58
- "tsup": "8.5.0"
58
+ "tsdown": "0.20.1"
59
59
  },
60
60
  "engines": {
61
61
  "node": ">=20"
@@ -1,177 +0,0 @@
1
- var __create = Object.create;
2
- var __defProp = Object.defineProperty;
3
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
- var __getOwnPropNames = Object.getOwnPropertyNames;
5
- var __getProtoOf = Object.getPrototypeOf;
6
- var __hasOwnProp = Object.prototype.hasOwnProperty;
7
- var __export = (target, all) => {
8
- for (var name in all)
9
- __defProp(target, name, { get: all[name], enumerable: true });
10
- };
11
- var __copyProps = (to, from, except, desc) => {
12
- if (from && typeof from === "object" || typeof from === "function") {
13
- for (let key of __getOwnPropNames(from))
14
- if (!__hasOwnProp.call(to, key) && key !== except)
15
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
- }
17
- return to;
18
- };
19
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
20
- // If the importer is in node compatibility mode or this is not an ESM
21
- // file that has been converted to a CommonJS file using a Babel-
22
- // compatible transform (i.e. "__esModule" has not been set), then set
23
- // "default" to the CommonJS "module.exports" for node compatibility.
24
- isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
25
- mod
26
- ));
27
- var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
28
- var context_exports = {};
29
- __export(context_exports, {
30
- default: () => HoaContext
31
- });
32
- module.exports = __toCommonJS(context_exports);
33
- var import_http_error = __toESM(require("./lib/http-error.js"), 1);
34
- var import_utils = require("./lib/utils.js");
35
- class HoaContext {
36
- /**
37
- * Create a context for a single HTTP request.
38
- *
39
- * @param {Object} [options={}]
40
- * @param {Request} [options.request] - Web Standard Request
41
- * @param {any} [options.env] - Environment (platform-specific)
42
- * @param {any} [options.executionCtx] - Execution context (platform-specific)
43
- * @public
44
- */
45
- constructor(options = {}) {
46
- this.request = options.request;
47
- this.env = options.env;
48
- this.executionCtx = options.executionCtx;
49
- this.state = /* @__PURE__ */ Object.create(null);
50
- }
51
- /**
52
- * Throw an HttpError.
53
- *
54
- * @param {number} status - HTTP status code
55
- * @param {string|{message?: string, cause?: any, headers?: HeadersInit}} [messageOrOptions] - Error message or options object
56
- * @throws {HttpError}
57
- * @public
58
- */
59
- throw(...args) {
60
- throw new import_http_error.default(...args);
61
- }
62
- /**
63
- * Assert condition or throw an HttpError.
64
- *
65
- * @param {any} value - Condition to assert
66
- * @param {...any} args - Arguments passed to HttpError constructor
67
- * @throws {HttpError}
68
- * @public
69
- */
70
- assert(value, ...args) {
71
- if (value) return;
72
- throw new import_http_error.default(...args);
73
- }
74
- /**
75
- * Default error handling and response builder.
76
- *
77
- * @param {Error} err - Error to handle
78
- * @returns {Response} Web Standard Response object
79
- * @private
80
- */
81
- onerror(err) {
82
- const { res } = this;
83
- const isNativeError = Object.prototype.toString.call(err) === "[object Error]" || err instanceof Error;
84
- if (!isNativeError) err = new Error(`non-error thrown: ${JSON.stringify(err)}`);
85
- this.app.onerror(err, this);
86
- res.headers = new Headers();
87
- res.set(err.headers);
88
- res.type = "text";
89
- let status = err.status || err.statusCode;
90
- if (typeof status !== "number" || !import_utils.statusTextMapping[status]) status = 500;
91
- const message = import_utils.statusTextMapping[status];
92
- const msg = err.expose ? err.message : message;
93
- res.status = status;
94
- res.body = msg;
95
- return new Response(res.body, {
96
- status: res.status,
97
- headers: res._headers
98
- });
99
- }
100
- /**
101
- * Build Web Standard Response from current context state.
102
- * Handles various body types, HEAD requests, and status-specific behaviors.
103
- *
104
- * @returns {Response} Web Standard Response object
105
- * @public
106
- */
107
- get response() {
108
- const { res, req } = this;
109
- let body = res.body;
110
- if (req.method === "HEAD") {
111
- if (!res.has("Content-Length")) {
112
- const contentLength = res.length;
113
- if (Number.isInteger(contentLength)) {
114
- res.length = contentLength;
115
- }
116
- }
117
- return new Response(null, {
118
- status: res.status,
119
- statusText: res.statusText,
120
- headers: res._headers
121
- });
122
- }
123
- if (import_utils.statusEmptyMapping[res.status]) {
124
- res.body = null;
125
- return new Response(null, {
126
- status: res.status,
127
- statusText: res.statusText,
128
- headers: res._headers
129
- });
130
- }
131
- if (body == null) {
132
- if (res._explicitNullBody) {
133
- res.delete("Content-Type");
134
- res.delete("Transfer-Encoding");
135
- res.set("Content-Length", "0");
136
- }
137
- return new Response(null, {
138
- status: res.status,
139
- statusText: res.statusText,
140
- headers: res._headers
141
- });
142
- }
143
- if (typeof body === "string" || body instanceof Blob || body instanceof ArrayBuffer || ArrayBuffer.isView(body) || body instanceof ReadableStream || body instanceof FormData || body instanceof URLSearchParams) {
144
- return new Response(body, {
145
- status: res.status,
146
- statusText: res.statusText,
147
- headers: res._headers
148
- });
149
- }
150
- if (body instanceof Response) {
151
- return new Response(body.body, {
152
- status: res.status,
153
- statusText: res.statusText,
154
- headers: res._headers
155
- });
156
- }
157
- body = JSON.stringify(body);
158
- return new Response(body, {
159
- status: res.status,
160
- statusText: res.statusText,
161
- headers: res._headers
162
- });
163
- }
164
- /**
165
- * Return JSON representation of the context.
166
- *
167
- * @returns {CtxJSON} JSON representation of context
168
- * @public
169
- */
170
- toJSON() {
171
- return {
172
- app: this.app.toJSON(),
173
- req: this.req.toJSON(),
174
- res: this.res.toJSON()
175
- };
176
- }
177
- }