@hyperspan/framework 1.0.0-alpha.7 → 1.0.0-alpha.9
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/package.json +1 -1
- package/src/actions.test.ts +24 -83
- package/src/cookies.ts +234 -0
- package/src/index.ts +1 -1
- package/src/server.test.ts +122 -1
- package/src/server.ts +26 -10
- package/src/types.ts +24 -1
- package/src/utils.test.ts +196 -0
- package/src/utils.ts +1 -1
package/package.json
CHANGED
package/src/actions.test.ts
CHANGED
|
@@ -1,92 +1,33 @@
|
|
|
1
1
|
import { test, expect, describe } from 'bun:test';
|
|
2
|
-
import {
|
|
2
|
+
import { createAction } from './actions';
|
|
3
3
|
import { html, render, type HSHtml } from '@hyperspan/html';
|
|
4
4
|
import { createContext } from './server';
|
|
5
5
|
import type { Hyperspan as HS } from './types';
|
|
6
6
|
import * as z from 'zod/v4';
|
|
7
7
|
|
|
8
|
-
describe('formDataToJSON', () => {
|
|
9
|
-
test('formDataToJSON returns empty object for empty FormData', () => {
|
|
10
|
-
const formData = new FormData();
|
|
11
|
-
const result = formDataToJSON(formData);
|
|
12
|
-
|
|
13
|
-
expect(result).toEqual({});
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
test('formDataToJSON handles simple FormData object', () => {
|
|
17
|
-
const formData = new FormData();
|
|
18
|
-
formData.append('name', 'John Doe');
|
|
19
|
-
formData.append('email', 'john@example.com');
|
|
20
|
-
formData.append('age', '30');
|
|
21
|
-
|
|
22
|
-
const result = formDataToJSON(formData);
|
|
23
|
-
|
|
24
|
-
expect(result).toEqual({
|
|
25
|
-
name: 'John Doe',
|
|
26
|
-
email: 'john@example.com',
|
|
27
|
-
age: '30',
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test('formDataToJSON handles complex FormData with nested fields', () => {
|
|
32
|
-
const formData = new FormData();
|
|
33
|
-
formData.append('user[firstName]', 'John');
|
|
34
|
-
formData.append('user[lastName]', 'Doe');
|
|
35
|
-
formData.append('user[email]', 'john@example.com');
|
|
36
|
-
formData.append('user[address][street]', '123 Main St');
|
|
37
|
-
formData.append('user[address][city]', 'New York');
|
|
38
|
-
formData.append('user[address][zip]', '10001');
|
|
39
|
-
|
|
40
|
-
const result = formDataToJSON(formData);
|
|
41
|
-
|
|
42
|
-
expect(result).toEqual({
|
|
43
|
-
user: {
|
|
44
|
-
firstName: 'John',
|
|
45
|
-
lastName: 'Doe',
|
|
46
|
-
email: 'john@example.com',
|
|
47
|
-
address: {
|
|
48
|
-
street: '123 Main St',
|
|
49
|
-
city: 'New York',
|
|
50
|
-
zip: '10001',
|
|
51
|
-
},
|
|
52
|
-
},
|
|
53
|
-
} as any);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test('formDataToJSON handles FormData with array of values', () => {
|
|
57
|
-
const formData = new FormData();
|
|
58
|
-
formData.append('tags', 'javascript');
|
|
59
|
-
formData.append('tags', 'typescript');
|
|
60
|
-
formData.append('tags', 'nodejs');
|
|
61
|
-
formData.append('colors[]', 'red');
|
|
62
|
-
formData.append('colors[]', 'green');
|
|
63
|
-
formData.append('colors[]', 'blue');
|
|
64
|
-
|
|
65
|
-
const result = formDataToJSON(formData);
|
|
66
|
-
|
|
67
|
-
expect(result).toEqual({
|
|
68
|
-
tags: ['javascript', 'typescript', 'nodejs'],
|
|
69
|
-
colors: ['red', 'green', 'blue'],
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
|
|
74
8
|
describe('createAction', () => {
|
|
75
9
|
test('creates an action with a simple form and no schema', async () => {
|
|
76
10
|
const action = createAction({
|
|
77
|
-
|
|
78
|
-
|
|
11
|
+
name: 'test',
|
|
12
|
+
schema: z.object({
|
|
13
|
+
name: z.string().min(1, 'Name is required'),
|
|
14
|
+
}),
|
|
15
|
+
}).form((c) => {
|
|
16
|
+
return html`
|
|
79
17
|
<form>
|
|
80
18
|
<input type="text" name="name" />
|
|
81
19
|
<button type="submit">Submit</button>
|
|
82
20
|
</form>
|
|
83
21
|
`;
|
|
84
|
-
|
|
22
|
+
}).post(async (c, { data }) => {
|
|
23
|
+
return c.res.html(`
|
|
24
|
+
<p>Hello, ${data?.name}!</p>
|
|
25
|
+
`);
|
|
85
26
|
});
|
|
86
27
|
|
|
87
28
|
expect(action).toBeDefined();
|
|
88
29
|
expect(action._kind).toBe('hsAction');
|
|
89
|
-
expect(action.
|
|
30
|
+
expect(action._path()).toContain('/__actions/');
|
|
90
31
|
|
|
91
32
|
// Test render method
|
|
92
33
|
const request = new Request('http://localhost:3000/');
|
|
@@ -106,17 +47,17 @@ describe('createAction', () => {
|
|
|
106
47
|
});
|
|
107
48
|
|
|
108
49
|
const action = createAction({
|
|
50
|
+
name: 'test',
|
|
109
51
|
schema,
|
|
110
|
-
|
|
111
|
-
|
|
52
|
+
}).form((c, { data }) => {
|
|
53
|
+
return html`
|
|
112
54
|
<form>
|
|
113
|
-
<input type="text" name="name"
|
|
114
|
-
<input type="email" name="email"
|
|
55
|
+
<input type="text" name="name" />
|
|
56
|
+
<input type="email" name="email" />
|
|
115
57
|
<button type="submit">Submit</button>
|
|
116
58
|
</form>
|
|
117
59
|
`;
|
|
118
|
-
|
|
119
|
-
}).post(async (c: HS.Context, { data }) => {
|
|
60
|
+
}).post(async (c, { data }) => {
|
|
120
61
|
return c.res.html(`
|
|
121
62
|
<p>Hello, ${data?.name}!</p>
|
|
122
63
|
<p>Your email is ${data?.email}.</p>
|
|
@@ -125,7 +66,7 @@ describe('createAction', () => {
|
|
|
125
66
|
|
|
126
67
|
expect(action).toBeDefined();
|
|
127
68
|
expect(action._kind).toBe('hsAction');
|
|
128
|
-
expect(action.
|
|
69
|
+
expect(action._path()).toContain('/__actions/');
|
|
129
70
|
|
|
130
71
|
// Test render method
|
|
131
72
|
const request = new Request('http://localhost:3000/');
|
|
@@ -142,7 +83,7 @@ describe('createAction', () => {
|
|
|
142
83
|
formData.append('name', 'John Doe');
|
|
143
84
|
formData.append('email', 'john@example.com');
|
|
144
85
|
|
|
145
|
-
const postRequest = new Request(`http://localhost:3000${action.
|
|
86
|
+
const postRequest = new Request(`http://localhost:3000${action._path()}`, {
|
|
146
87
|
method: 'POST',
|
|
147
88
|
body: formData,
|
|
148
89
|
});
|
|
@@ -163,9 +104,10 @@ describe('createAction', () => {
|
|
|
163
104
|
});
|
|
164
105
|
|
|
165
106
|
const action = createAction({
|
|
107
|
+
name: 'test',
|
|
166
108
|
schema,
|
|
167
|
-
|
|
168
|
-
|
|
109
|
+
}).form((c, { data, error }) => {
|
|
110
|
+
return html`
|
|
169
111
|
<form>
|
|
170
112
|
<input type="text" name="name" value="${data?.name || ''}" />
|
|
171
113
|
${error ? html`<div class="error">Validation failed</div>` : ''}
|
|
@@ -173,8 +115,7 @@ describe('createAction', () => {
|
|
|
173
115
|
<button type="submit">Submit</button>
|
|
174
116
|
</form>
|
|
175
117
|
`;
|
|
176
|
-
|
|
177
|
-
}).post(async (c: HS.Context, { data }) => {
|
|
118
|
+
}).post(async (c, { data }) => {
|
|
178
119
|
return c.res.html(`
|
|
179
120
|
<p>Hello, ${data?.name}!</p>
|
|
180
121
|
<p>Your email is ${data?.email}.</p>
|
package/src/cookies.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import type { Hyperspan as HS } from './types';
|
|
2
|
+
|
|
3
|
+
const REGEXP_PAIR_SPLIT = /; */;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* RegExp to match field-content in RFC 7230 sec 3.2
|
|
7
|
+
*
|
|
8
|
+
* field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]
|
|
9
|
+
* field-vchar = VCHAR / obs-text
|
|
10
|
+
* obs-text = %x80-FF
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const REGEXP_FIELD_CONTENT = /^[\u0009\u0020-\u007e\u0080-\u00ff]+$/;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Cookie parsing and serialization
|
|
17
|
+
*/
|
|
18
|
+
export class Cookies implements HS.Cookies {
|
|
19
|
+
_req: Request;
|
|
20
|
+
_responseHeaders: HS.Cookies['_responseHeaders'];
|
|
21
|
+
_parsedCookies: HS.Cookies['_parsedCookies'] = {};
|
|
22
|
+
_encrypt: HS.Cookies['_encrypt'];
|
|
23
|
+
_decrypt: HS.Cookies['_decrypt'];
|
|
24
|
+
constructor(req: Request, responseHeaders: HS.Cookies['_responseHeaders'] = undefined) {
|
|
25
|
+
this._req = req;
|
|
26
|
+
this._responseHeaders = responseHeaders;
|
|
27
|
+
this._parsedCookies = parse(req.headers.get('Cookie') || '');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get(name: string): string | undefined {
|
|
31
|
+
const value = this._parsedCookies[name];
|
|
32
|
+
if (value && this._decrypt) {
|
|
33
|
+
return this._decrypt(value);
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
set(name: string, value: string, options?: HS.CookieOptions) {
|
|
39
|
+
if (!this._responseHeaders) {
|
|
40
|
+
throw new Error('Set cookies in the response object. Cookies can only be read from the request object.');
|
|
41
|
+
}
|
|
42
|
+
if (this._encrypt) {
|
|
43
|
+
value = this._encrypt(value);
|
|
44
|
+
}
|
|
45
|
+
this._responseHeaders.set('Set-Cookie', serialize(name, value, options));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
delete(name: string) {
|
|
49
|
+
this.set(name, '', { expires: new Date(0) });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Set the encoder and decoder functions for the cookies and re-parse the cookie header
|
|
54
|
+
*/
|
|
55
|
+
setEncryption(encrypt: HS.Cookies['_encrypt'], decrypt: HS.Cookies['_decrypt']) {
|
|
56
|
+
this._encrypt = encrypt;
|
|
57
|
+
this._decrypt = decrypt;
|
|
58
|
+
this._parsedCookies = parse(this._req.headers.get('Cookie') || '');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/*!
|
|
63
|
+
* cookie
|
|
64
|
+
* @source https://github.com/jkohrman/cookie-parse/blob/master/index.js
|
|
65
|
+
* Copyright(c) 2012-2014 Roman Shtylman
|
|
66
|
+
* Copyright(c) 2015 Douglas Christopher Wilson
|
|
67
|
+
* Copyright(c) 2016 Jeff Kohrman
|
|
68
|
+
* MIT Licensed
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parse a cookie header.
|
|
73
|
+
*
|
|
74
|
+
* Parse the given cookie header string into an object
|
|
75
|
+
* The object has the various cookies as keys(names) => values
|
|
76
|
+
*
|
|
77
|
+
* @param {string} str
|
|
78
|
+
* @param {object} [options]
|
|
79
|
+
* @return {object}
|
|
80
|
+
* @public
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
function parse(str: string): Record<string, string | any | undefined> {
|
|
84
|
+
if (typeof str !== 'string') {
|
|
85
|
+
throw new TypeError('argument str must be a string');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const obj = {}
|
|
89
|
+
const pairs = str.split(REGEXP_PAIR_SPLIT);
|
|
90
|
+
|
|
91
|
+
for (let i = 0; i < pairs.length; i++) {
|
|
92
|
+
const pair = pairs[i];
|
|
93
|
+
let eq_idx = pair.indexOf('=');
|
|
94
|
+
|
|
95
|
+
// set true for things that don't look like key=value
|
|
96
|
+
let key;
|
|
97
|
+
let val;
|
|
98
|
+
if (eq_idx < 0) {
|
|
99
|
+
key = pair.trim();
|
|
100
|
+
val = 'true';
|
|
101
|
+
} else {
|
|
102
|
+
key = pair.substring(0, eq_idx).trim()
|
|
103
|
+
val = pair.substring(eq_idx + 1, eq_idx + 1 + pair.length).trim();
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// quoted values
|
|
107
|
+
if ('"' == val[0]) {
|
|
108
|
+
val = val.slice(1, -1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// only assign once
|
|
112
|
+
// @ts-ignore
|
|
113
|
+
if (undefined == obj[key]) {
|
|
114
|
+
// @ts-ignore
|
|
115
|
+
obj[key] = tryDecode(val, decodeURIComponent);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return obj;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Serialize data into a cookie header.
|
|
124
|
+
*
|
|
125
|
+
* Serialize the a name value pair into a cookie string suitable for
|
|
126
|
+
* http headers. An optional options object specified cookie parameters.
|
|
127
|
+
*
|
|
128
|
+
* serialize('foo', 'bar', { httpOnly: true })
|
|
129
|
+
* => "foo=bar; httpOnly"
|
|
130
|
+
*
|
|
131
|
+
* @param {string} name
|
|
132
|
+
* @param {string} val
|
|
133
|
+
* @param {object} [options]
|
|
134
|
+
* @return {string}
|
|
135
|
+
* @public
|
|
136
|
+
*/
|
|
137
|
+
type SerializeOptions = {
|
|
138
|
+
encrypt?: (str: string) => string;
|
|
139
|
+
maxAge?: number;
|
|
140
|
+
domain?: string;
|
|
141
|
+
path?: string;
|
|
142
|
+
expires?: Date;
|
|
143
|
+
httpOnly?: boolean;
|
|
144
|
+
secure?: boolean;
|
|
145
|
+
sameSite?: 'lax' | 'strict' | true;
|
|
146
|
+
};
|
|
147
|
+
function serialize(name: string, val: string, options: SerializeOptions = {}) {
|
|
148
|
+
const opt = options || {};
|
|
149
|
+
|
|
150
|
+
if (!REGEXP_FIELD_CONTENT.test(name)) {
|
|
151
|
+
throw new TypeError('argument name is invalid');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let value = encodeURIComponent(val);
|
|
155
|
+
|
|
156
|
+
if (value && !REGEXP_FIELD_CONTENT.test(value)) {
|
|
157
|
+
throw new TypeError('argument val is invalid');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let str = name + '=' + value;
|
|
161
|
+
|
|
162
|
+
if (null != opt.maxAge) {
|
|
163
|
+
const maxAge = opt.maxAge - 0;
|
|
164
|
+
if (isNaN(maxAge)) throw new Error('maxAge should be a Number');
|
|
165
|
+
str += '; Max-Age=' + Math.floor(maxAge);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (opt.domain) {
|
|
169
|
+
if (!REGEXP_FIELD_CONTENT.test(opt.domain)) {
|
|
170
|
+
throw new TypeError('option domain is invalid');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
str += '; Domain=' + opt.domain;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (opt.path) {
|
|
177
|
+
if (!REGEXP_FIELD_CONTENT.test(opt.path)) {
|
|
178
|
+
throw new TypeError('option path is invalid');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
str += '; Path=' + opt.path;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (opt.expires) {
|
|
185
|
+
if (typeof opt.expires.toUTCString !== 'function') {
|
|
186
|
+
throw new TypeError('option expires must be a Date object');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
str += '; Expires=' + opt.expires.toUTCString();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (opt.httpOnly) {
|
|
193
|
+
str += '; HttpOnly';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (opt.secure) {
|
|
197
|
+
str += '; Secure';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (opt.sameSite) {
|
|
201
|
+
const sameSite = typeof opt.sameSite === 'string'
|
|
202
|
+
? opt.sameSite.toLowerCase() : opt.sameSite;
|
|
203
|
+
|
|
204
|
+
switch (sameSite) {
|
|
205
|
+
case true:
|
|
206
|
+
case 'strict':
|
|
207
|
+
str += '; SameSite=Strict';
|
|
208
|
+
break;
|
|
209
|
+
case 'lax':
|
|
210
|
+
str += '; SameSite=Lax';
|
|
211
|
+
break;
|
|
212
|
+
default:
|
|
213
|
+
throw new TypeError('option sameSite is invalid');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return str;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Try decoding a string using a decoding function.
|
|
222
|
+
*
|
|
223
|
+
* @param {string} str
|
|
224
|
+
* @param {function} decode
|
|
225
|
+
* @private
|
|
226
|
+
*/
|
|
227
|
+
|
|
228
|
+
function tryDecode(str: string, decode: (str: string) => string) {
|
|
229
|
+
try {
|
|
230
|
+
return decode(str);
|
|
231
|
+
} catch (e) {
|
|
232
|
+
return str;
|
|
233
|
+
}
|
|
234
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { createConfig, createContext, createRoute, createServer, getRunnableRoute, StreamResponse, IS_PROD } from './server';
|
|
1
|
+
export { createConfig, createContext, createRoute, createServer, getRunnableRoute, StreamResponse, IS_PROD, HTTPException } from './server';
|
|
2
2
|
export type { Hyperspan } from './types';
|
package/src/server.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { test, expect } from 'bun:test';
|
|
2
|
-
import { createRoute, createServer } from './server';
|
|
2
|
+
import { createRoute, createServer, createContext } from './server';
|
|
3
3
|
import type { Hyperspan as HS } from './types';
|
|
4
4
|
|
|
5
5
|
test('route fetch() returns a Response', async () => {
|
|
@@ -89,3 +89,124 @@ test('returns 405 when route path matches but HTTP method does not', async () =>
|
|
|
89
89
|
const text = await response.text();
|
|
90
90
|
expect(text).toContain('Method not allowed');
|
|
91
91
|
});
|
|
92
|
+
|
|
93
|
+
test('createContext() can get and set cookies', () => {
|
|
94
|
+
// Create a request with cookies in the Cookie header
|
|
95
|
+
const request = new Request('http://localhost:3000/', {
|
|
96
|
+
headers: {
|
|
97
|
+
'Cookie': 'sessionId=abc123; theme=dark; userId=42',
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Create context from the request
|
|
102
|
+
const context = createContext(request);
|
|
103
|
+
|
|
104
|
+
// Test reading cookies from request
|
|
105
|
+
expect(context.req.cookies.get('sessionId')).toBe('abc123');
|
|
106
|
+
expect(context.req.cookies.get('theme')).toBe('dark');
|
|
107
|
+
expect(context.req.cookies.get('userId')).toBe('42');
|
|
108
|
+
expect(context.req.cookies.get('nonExistent')).toBeUndefined();
|
|
109
|
+
|
|
110
|
+
// Test setting a simple cookie in response
|
|
111
|
+
context.res.cookies.set('newCookie', 'newValue');
|
|
112
|
+
let setCookieHeader = context.res.headers.get('Set-Cookie');
|
|
113
|
+
expect(setCookieHeader).toBeTruthy();
|
|
114
|
+
expect(setCookieHeader).toContain('newCookie=newValue');
|
|
115
|
+
|
|
116
|
+
// Test setting a cookie with options (this will overwrite the previous Set-Cookie header)
|
|
117
|
+
context.res.cookies.set('secureCookie', 'secureValue', {
|
|
118
|
+
httpOnly: true,
|
|
119
|
+
secure: true,
|
|
120
|
+
sameSite: 'strict',
|
|
121
|
+
maxAge: 3600,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Verify Set-Cookie header contains the last cookie set
|
|
125
|
+
setCookieHeader = context.res.headers.get('Set-Cookie');
|
|
126
|
+
expect(setCookieHeader).toBeTruthy();
|
|
127
|
+
expect(setCookieHeader).toContain('secureCookie=secureValue');
|
|
128
|
+
expect(setCookieHeader).toContain('HttpOnly');
|
|
129
|
+
expect(setCookieHeader).toContain('Secure');
|
|
130
|
+
expect(setCookieHeader).toContain('SameSite=Strict');
|
|
131
|
+
expect(setCookieHeader).toContain('Max-Age=3600');
|
|
132
|
+
|
|
133
|
+
// Verify the previous cookie was overwritten
|
|
134
|
+
expect(setCookieHeader).not.toContain('newCookie=newValue');
|
|
135
|
+
|
|
136
|
+
// Test deleting a cookie
|
|
137
|
+
context.res.cookies.delete('sessionId');
|
|
138
|
+
setCookieHeader = context.res.headers.get('Set-Cookie');
|
|
139
|
+
expect(setCookieHeader).toBeTruthy();
|
|
140
|
+
if (setCookieHeader) {
|
|
141
|
+
expect(setCookieHeader).toContain('sessionId=');
|
|
142
|
+
expect(setCookieHeader).toContain('Expires=');
|
|
143
|
+
// Verify it's set to expire in the past (deleted)
|
|
144
|
+
const expiresMatch = setCookieHeader.match(/Expires=([^;]+)/);
|
|
145
|
+
expect(expiresMatch).toBeTruthy();
|
|
146
|
+
if (expiresMatch) {
|
|
147
|
+
const expiresDate = new Date(expiresMatch[1]);
|
|
148
|
+
expect(expiresDate.getTime()).toBeLessThanOrEqual(new Date(0).getTime());
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('createContext() merge() function preserves custom headers when using response methods', () => {
|
|
154
|
+
// Create a request
|
|
155
|
+
const request = new Request('http://localhost:3000/');
|
|
156
|
+
|
|
157
|
+
// Create context from the request
|
|
158
|
+
const context = createContext(request);
|
|
159
|
+
|
|
160
|
+
// Set custom headers on the context response
|
|
161
|
+
context.res.headers.set('X-Custom-Header', 'custom-value');
|
|
162
|
+
context.res.headers.set('X-Another-Header', 'another-value');
|
|
163
|
+
context.res.headers.set('Authorization', 'Bearer token123');
|
|
164
|
+
|
|
165
|
+
// Use html() method which should merge headers
|
|
166
|
+
const response = context.res.html('<h1>Test</h1>');
|
|
167
|
+
|
|
168
|
+
// Verify the response has both the custom headers and the Content-Type header
|
|
169
|
+
expect(response.headers.get('X-Custom-Header')).toBe('custom-value');
|
|
170
|
+
expect(response.headers.get('X-Another-Header')).toBe('another-value');
|
|
171
|
+
expect(response.headers.get('Authorization')).toBe('Bearer token123');
|
|
172
|
+
expect(response.headers.get('Content-Type')).toBe('text/html; charset=UTF-8');
|
|
173
|
+
|
|
174
|
+
// Verify response body is correct
|
|
175
|
+
expect(response.status).toBe(200);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('createContext() merge() function preserves custom headers with json() method', () => {
|
|
179
|
+
const request = new Request('http://localhost:3000/');
|
|
180
|
+
const context = createContext(request);
|
|
181
|
+
|
|
182
|
+
// Set custom headers
|
|
183
|
+
context.res.headers.set('X-API-Version', 'v1');
|
|
184
|
+
context.res.headers.set('X-Request-ID', 'req-123');
|
|
185
|
+
|
|
186
|
+
// Use json() method
|
|
187
|
+
const response = context.res.json({ message: 'Hello' });
|
|
188
|
+
|
|
189
|
+
// Verify headers are merged
|
|
190
|
+
expect(response.headers.get('X-API-Version')).toBe('v1');
|
|
191
|
+
expect(response.headers.get('X-Request-ID')).toBe('req-123');
|
|
192
|
+
expect(response.headers.get('Content-Type')).toBe('application/json');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('createContext() merge() function allows response headers to override context headers', () => {
|
|
196
|
+
const request = new Request('http://localhost:3000/');
|
|
197
|
+
const context = createContext(request);
|
|
198
|
+
|
|
199
|
+
// Set a header on context
|
|
200
|
+
context.res.headers.set('X-Header', 'context-value');
|
|
201
|
+
|
|
202
|
+
// Use html() with options that include the same header (should override)
|
|
203
|
+
const response = context.res.html('<h1>Test</h1>', {
|
|
204
|
+
headers: {
|
|
205
|
+
'X-Header': 'response-value',
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Response header should override context header
|
|
210
|
+
expect(response.headers.get('X-Header')).toBe('response-value');
|
|
211
|
+
expect(response.headers.get('Content-Type')).toBe('text/html; charset=UTF-8');
|
|
212
|
+
});
|
package/src/server.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { HSHtml, html, isHSHtml, renderStream, renderAsync, render } from '@hyperspan/html';
|
|
2
2
|
import { executeMiddleware } from './middleware';
|
|
3
|
-
import type { Hyperspan as HS } from './types';
|
|
4
3
|
import { clientJSPlugin } from './plugins';
|
|
5
4
|
import { parsePath } from './utils';
|
|
6
|
-
|
|
5
|
+
import { Cookies } from './cookies';
|
|
6
|
+
|
|
7
|
+
import type { Hyperspan as HS } from './types';
|
|
7
8
|
|
|
8
9
|
export const IS_PROD = process.env.NODE_ENV === 'production';
|
|
9
10
|
|
|
@@ -37,6 +38,19 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
37
38
|
// @ts-ignore - Bun will put 'params' on the Request object even though it's not standardized
|
|
38
39
|
const params: HS.RouteParamsParser<path> = req?.params || {};
|
|
39
40
|
|
|
41
|
+
const merge = (response: Response) => {
|
|
42
|
+
// Convert headers to plain objects and merge (response headers override context headers)
|
|
43
|
+
const mergedHeaders = {
|
|
44
|
+
...Object.fromEntries(headers.entries()),
|
|
45
|
+
...Object.fromEntries(response.headers.entries()),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return new Response(response.body, {
|
|
49
|
+
status: response.status,
|
|
50
|
+
headers: mergedHeaders,
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
|
|
40
54
|
return {
|
|
41
55
|
vars: {},
|
|
42
56
|
route: {
|
|
@@ -50,20 +64,23 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
50
64
|
method,
|
|
51
65
|
headers,
|
|
52
66
|
query,
|
|
67
|
+
cookies: new Cookies(req),
|
|
53
68
|
async text() { return req.text() },
|
|
54
69
|
async json<T = unknown>() { return await req.json() as T },
|
|
55
70
|
async formData<T = unknown>() { return await req.formData() as T },
|
|
56
71
|
async urlencoded() { return new URLSearchParams(await req.text()) },
|
|
57
72
|
},
|
|
58
73
|
res: {
|
|
59
|
-
|
|
74
|
+
cookies: new Cookies(req, headers),
|
|
75
|
+
headers,
|
|
60
76
|
raw: new Response(),
|
|
61
|
-
html: (html: string, options?: { status?: number; headers?: Headers | Record<string, string> }) => new Response(html, { ...options, headers: { 'Content-Type': 'text/html; charset=UTF-8', ...options?.headers } }),
|
|
62
|
-
json: (json: any, options?: { status?: number; headers?: Headers | Record<string, string> }) => new Response(JSON.stringify(json), { ...options, headers: { 'Content-Type': 'application/json', ...options?.headers } }),
|
|
63
|
-
text: (text: string, options?: { status?: number; headers?: Headers | Record<string, string> }) => new Response(text, { ...options, headers: { 'Content-Type': 'text/plain; charset=UTF-8', ...options?.headers } }),
|
|
64
|
-
redirect: (url: string, options?: { status?: number; headers?: Headers | Record<string, string> }) => new Response(null, { status: 302, headers: { Location: url, ...options?.headers } }),
|
|
65
|
-
error: (error: Error, options?: { status?: number; headers?: Headers | Record<string, string> }) => new Response(error.message, { status: 500, ...options }),
|
|
66
|
-
notFound: (options?: { status?: number; headers?: Headers | Record<string, string> }) => new Response('Not Found', { status: 404, ...options }),
|
|
77
|
+
html: (html: string, options?: { status?: number; headers?: Headers | Record<string, string> }) => merge(new Response(html, { ...options, headers: { 'Content-Type': 'text/html; charset=UTF-8', ...options?.headers } })),
|
|
78
|
+
json: (json: any, options?: { status?: number; headers?: Headers | Record<string, string> }) => merge(new Response(JSON.stringify(json), { ...options, headers: { 'Content-Type': 'application/json', ...options?.headers } })),
|
|
79
|
+
text: (text: string, options?: { status?: number; headers?: Headers | Record<string, string> }) => merge(new Response(text, { ...options, headers: { 'Content-Type': 'text/plain; charset=UTF-8', ...options?.headers } })),
|
|
80
|
+
redirect: (url: string, options?: { status?: number; headers?: Headers | Record<string, string> }) => merge(new Response(null, { status: 302, headers: { Location: url, ...options?.headers } })),
|
|
81
|
+
error: (error: Error, options?: { status?: number; headers?: Headers | Record<string, string> }) => merge(new Response(error.message, { status: 500, ...options })),
|
|
82
|
+
notFound: (options?: { status?: number; headers?: Headers | Record<string, string> }) => merge(new Response('Not Found', { status: 404, ...options })),
|
|
83
|
+
merge,
|
|
67
84
|
},
|
|
68
85
|
};
|
|
69
86
|
}
|
|
@@ -79,7 +96,6 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
|
79
96
|
|
|
80
97
|
const api: HS.Route = {
|
|
81
98
|
_kind: 'hsRoute',
|
|
82
|
-
_name: config.name,
|
|
83
99
|
_config: config,
|
|
84
100
|
_methods: () => Object.keys(_handlers),
|
|
85
101
|
_path() {
|
package/src/types.ts
CHANGED
|
@@ -29,6 +29,26 @@ export namespace Hyperspan {
|
|
|
29
29
|
afterRoutesAdded?: (server: Hyperspan.Server) => void;
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
+
export type CookieOptions = {
|
|
33
|
+
maxAge?: number;
|
|
34
|
+
domain?: string;
|
|
35
|
+
path?: string;
|
|
36
|
+
expires?: Date;
|
|
37
|
+
httpOnly?: boolean;
|
|
38
|
+
secure?: boolean;
|
|
39
|
+
sameSite?: 'lax' | 'strict' | true;
|
|
40
|
+
};
|
|
41
|
+
export type Cookies = {
|
|
42
|
+
_req: Request;
|
|
43
|
+
_responseHeaders: Headers | undefined;
|
|
44
|
+
_parsedCookies: Record<string, any>;
|
|
45
|
+
_encrypt: ((str: string) => string) | undefined;
|
|
46
|
+
_decrypt: ((str: string) => string) | undefined;
|
|
47
|
+
get: (name: string) => string | undefined;
|
|
48
|
+
set: (name: string, value: string, options?: CookieOptions) => void;
|
|
49
|
+
delete: (name: string) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
32
52
|
export interface Context {
|
|
33
53
|
vars: Record<string, any>;
|
|
34
54
|
route: {
|
|
@@ -42,12 +62,14 @@ export namespace Hyperspan {
|
|
|
42
62
|
method: string; // Always uppercase
|
|
43
63
|
headers: Headers; // Case-insensitive
|
|
44
64
|
query: URLSearchParams;
|
|
65
|
+
cookies: Hyperspan.Cookies;
|
|
45
66
|
text: () => Promise<string>;
|
|
46
67
|
json<T = unknown>(): Promise<T>;
|
|
47
|
-
formData
|
|
68
|
+
formData(): Promise<FormData>;
|
|
48
69
|
urlencoded(): Promise<URLSearchParams>;
|
|
49
70
|
};
|
|
50
71
|
res: {
|
|
72
|
+
cookies: Hyperspan.Cookies;
|
|
51
73
|
headers: Headers; // Headers to merge with final outgoing response
|
|
52
74
|
html: (html: string, options?: { status?: number; headers?: Record<string, string> }) => Response
|
|
53
75
|
json: (json: any, options?: { status?: number; headers?: Record<string, string> }) => Response;
|
|
@@ -55,6 +77,7 @@ export namespace Hyperspan {
|
|
|
55
77
|
redirect: (url: string, options?: { status?: number; headers?: Record<string, string> }) => Response;
|
|
56
78
|
error: (error: Error, options?: { status?: number; headers?: Record<string, string> }) => Response;
|
|
57
79
|
notFound: (options?: { status?: number; headers?: Record<string, string> }) => Response;
|
|
80
|
+
merge: (response: Response) => Response;
|
|
58
81
|
raw: Response;
|
|
59
82
|
};
|
|
60
83
|
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { test, expect, describe } from 'bun:test';
|
|
2
|
+
import { formDataToJSON, parsePath } from './utils';
|
|
3
|
+
|
|
4
|
+
describe('formDataToJSON', () => {
|
|
5
|
+
test('formDataToJSON returns empty object for empty FormData', () => {
|
|
6
|
+
const formData = new FormData();
|
|
7
|
+
const result = formDataToJSON(formData);
|
|
8
|
+
|
|
9
|
+
expect(result).toEqual({});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('formDataToJSON handles simple FormData object', () => {
|
|
13
|
+
const formData = new FormData();
|
|
14
|
+
formData.append('name', 'John Doe');
|
|
15
|
+
formData.append('email', 'john@example.com');
|
|
16
|
+
formData.append('age', '30');
|
|
17
|
+
|
|
18
|
+
const result = formDataToJSON(formData);
|
|
19
|
+
|
|
20
|
+
expect(result).toEqual({
|
|
21
|
+
name: 'John Doe',
|
|
22
|
+
email: 'john@example.com',
|
|
23
|
+
age: '30',
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('formDataToJSON handles complex FormData with nested fields', () => {
|
|
28
|
+
const formData = new FormData();
|
|
29
|
+
formData.append('user[firstName]', 'John');
|
|
30
|
+
formData.append('user[lastName]', 'Doe');
|
|
31
|
+
formData.append('user[email]', 'john@example.com');
|
|
32
|
+
formData.append('user[address][street]', '123 Main St');
|
|
33
|
+
formData.append('user[address][city]', 'New York');
|
|
34
|
+
formData.append('user[address][zip]', '10001');
|
|
35
|
+
|
|
36
|
+
const result = formDataToJSON(formData);
|
|
37
|
+
|
|
38
|
+
expect(result).toEqual({
|
|
39
|
+
user: {
|
|
40
|
+
firstName: 'John',
|
|
41
|
+
lastName: 'Doe',
|
|
42
|
+
email: 'john@example.com',
|
|
43
|
+
address: {
|
|
44
|
+
street: '123 Main St',
|
|
45
|
+
city: 'New York',
|
|
46
|
+
zip: '10001',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
} as any);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('formDataToJSON handles FormData with array of values', () => {
|
|
53
|
+
const formData = new FormData();
|
|
54
|
+
formData.append('tags', 'javascript');
|
|
55
|
+
formData.append('tags', 'typescript');
|
|
56
|
+
formData.append('tags', 'nodejs');
|
|
57
|
+
formData.append('colors[]', 'red');
|
|
58
|
+
formData.append('colors[]', 'green');
|
|
59
|
+
formData.append('colors[]', 'blue');
|
|
60
|
+
|
|
61
|
+
const result = formDataToJSON(formData);
|
|
62
|
+
|
|
63
|
+
expect(result).toEqual({
|
|
64
|
+
tags: ['javascript', 'typescript', 'nodejs'],
|
|
65
|
+
colors: ['red', 'green', 'blue'],
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('parsePath', () => {
|
|
71
|
+
test('parsePath returns root path for empty string', () => {
|
|
72
|
+
const result = parsePath('');
|
|
73
|
+
expect(result.path).toBe('/');
|
|
74
|
+
expect(result.params).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('parsePath handles simple path', () => {
|
|
78
|
+
const result = parsePath('users');
|
|
79
|
+
expect(result.path).toBe('/users');
|
|
80
|
+
expect(result.params).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('parsePath removes leading slash', () => {
|
|
84
|
+
const result = parsePath('/users');
|
|
85
|
+
expect(result.path).toBe('/users');
|
|
86
|
+
expect(result.params).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('parsePath removes trailing slash', () => {
|
|
90
|
+
const result = parsePath('users/');
|
|
91
|
+
expect(result.path).toBe('/users');
|
|
92
|
+
expect(result.params).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('parsePath removes both leading and trailing slashes', () => {
|
|
96
|
+
const result = parsePath('/users/');
|
|
97
|
+
expect(result.path).toBe('/users');
|
|
98
|
+
expect(result.params).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('parsePath handles nested paths', () => {
|
|
102
|
+
const result = parsePath('users/posts');
|
|
103
|
+
expect(result.path).toBe('/users/posts');
|
|
104
|
+
expect(result.params).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('parsePath lowercases path segments', () => {
|
|
108
|
+
const result = parsePath('Users/Posts');
|
|
109
|
+
expect(result.path).toBe('/users/posts');
|
|
110
|
+
expect(result.params).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('parsePath removes .ts extension', () => {
|
|
114
|
+
const result = parsePath('users.ts');
|
|
115
|
+
expect(result.path).toBe('/users');
|
|
116
|
+
expect(result.params).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('parsePath removes .js extension', () => {
|
|
120
|
+
const result = parsePath('users.js');
|
|
121
|
+
expect(result.path).toBe('/users');
|
|
122
|
+
expect(result.params).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('parsePath removes index from path', () => {
|
|
126
|
+
const result = parsePath('index');
|
|
127
|
+
expect(result.path).toBe('/');
|
|
128
|
+
expect(result.params).toEqual([]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('parsePath removes index.ts from path', () => {
|
|
132
|
+
const result = parsePath('index.ts');
|
|
133
|
+
expect(result.path).toBe('/');
|
|
134
|
+
expect(result.params).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('parsePath handles dynamic param with brackets', () => {
|
|
138
|
+
const result = parsePath('users/[userId]');
|
|
139
|
+
expect(result.path).toBe('/users/:userId');
|
|
140
|
+
expect(result.params).toEqual(['userId']);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('parsePath handles multiple dynamic params', () => {
|
|
144
|
+
const result = parsePath('users/[userId]/posts/[postId]');
|
|
145
|
+
expect(result.path).toBe('/users/:userId/posts/:postId');
|
|
146
|
+
expect(result.params).toEqual(['userId', 'postId']);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('parsePath handles catch-all param with spread', () => {
|
|
150
|
+
const result = parsePath('users/[...slug]');
|
|
151
|
+
expect(result.path).toBe('/users/*');
|
|
152
|
+
expect(result.params).toEqual(['slug']);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('parsePath handles catch-all param at root', () => {
|
|
156
|
+
const result = parsePath('[...slug]');
|
|
157
|
+
expect(result.path).toBe('/*');
|
|
158
|
+
expect(result.params).toEqual(['slug']);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('parsePath preserves param names in path but converts format', () => {
|
|
162
|
+
const result = parsePath('users/[userId]');
|
|
163
|
+
expect(result.path).toBe('/users/:userId');
|
|
164
|
+
expect(result.params).toEqual(['userId']);
|
|
165
|
+
// Param segment should not be lowercased
|
|
166
|
+
expect(result.path).toContain(':userId');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('parsePath handles complex nested path with params', () => {
|
|
170
|
+
const result = parsePath('/api/users/[userId]/posts/[postId]/comments');
|
|
171
|
+
expect(result.path).toBe('/api/users/:userId/posts/:postId/comments');
|
|
172
|
+
expect(result.params).toEqual(['userId', 'postId']);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('parsePath handles path with dots in param name', () => {
|
|
176
|
+
const result = parsePath('users/[user.id]');
|
|
177
|
+
expect(result.path).toBe('/users/:user.id');
|
|
178
|
+
expect(result.params).toEqual(['user.id']);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('parsePath handles mixed case with params', () => {
|
|
182
|
+
const result = parsePath('Users/[UserId]/Posts');
|
|
183
|
+
expect(result.path).toBe('/users/:UserId/posts');
|
|
184
|
+
expect(result.params).toEqual(['UserId']);
|
|
185
|
+
// Non-param segments should be lowercased, but param name preserved
|
|
186
|
+
expect(result.path).toContain('/users/');
|
|
187
|
+
expect(result.path).toContain('/posts');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('parsePath handles file path format', () => {
|
|
191
|
+
const result = parsePath('/routes/users/[userId].ts');
|
|
192
|
+
expect(result.path).toBe('/routes/users/:userId');
|
|
193
|
+
expect(result.params).toEqual(['userId']);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
package/src/utils.ts
CHANGED
|
@@ -33,7 +33,7 @@ export function parsePath(urlPath: string): { path: string, params: string[] } {
|
|
|
33
33
|
// Dynamic params
|
|
34
34
|
if (ROUTE_SEGMENT_REGEX.test(urlPath)) {
|
|
35
35
|
urlPath = urlPath.replace(ROUTE_SEGMENT_REGEX, (match: string) => {
|
|
36
|
-
const paramName = match.replace(/[^a-zA-Z_\.]+/g, '');
|
|
36
|
+
const paramName = match.replace(/[^a-zA-Z_\.]+/g, '').replace('...', '');
|
|
37
37
|
params.push(paramName);
|
|
38
38
|
|
|
39
39
|
if (match.includes('...')) {
|