@hyperspan/framework 1.0.0-alpha.1 → 1.0.0-alpha.10
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 +8 -8
- package/src/actions.test.ts +147 -0
- package/src/actions.ts +118 -0
- package/src/client/_hs/hyperspan-actions.client.ts +98 -0
- package/src/client/_hs/hyperspan-scripts.client.ts +31 -0
- package/src/client/_hs/hyperspan-streaming.client.ts +94 -0
- package/src/client/js.ts +0 -21
- package/src/cookies.ts +234 -0
- package/src/index.ts +2 -0
- package/src/plugins.ts +2 -4
- package/src/server.test.ts +141 -17
- package/src/server.ts +50 -81
- package/src/types.ts +52 -2
- package/src/utils.test.ts +196 -0
- package/src/utils.ts +135 -1
- package/tsconfig.json +1 -1
- package/src/clientjs/hyperspan-client.ts +0 -224
- /package/src/{clientjs → client/_hs}/idiomorph.ts +0 -0
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.append('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
ADDED
package/src/plugins.ts
CHANGED
|
@@ -46,6 +46,7 @@ export function clientJSPlugin(): HS.Plugin {
|
|
|
46
46
|
const esmName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', '');
|
|
47
47
|
JS_IMPORT_MAP.set(esmName, `${JS_PUBLIC_PATH}/${esmName}.js`);
|
|
48
48
|
|
|
49
|
+
// Get the contents of the file to extract the exports
|
|
49
50
|
const contents = await result.outputs[0].text();
|
|
50
51
|
const exportLine = EXPORT_REGEX.exec(contents);
|
|
51
52
|
|
|
@@ -65,10 +66,7 @@ export function clientJSPlugin(): HS.Plugin {
|
|
|
65
66
|
|
|
66
67
|
// Export a special object that can be used to render the client JS as a script tag
|
|
67
68
|
const moduleCode = `// hyperspan:processed
|
|
68
|
-
import { functionToString } from '@hyperspan/framework/
|
|
69
|
-
|
|
70
|
-
// Original file contents
|
|
71
|
-
${contents}
|
|
69
|
+
import { functionToString } from '@hyperspan/framework/client/js';
|
|
72
70
|
|
|
73
71
|
// hyperspan:client-js-plugin
|
|
74
72
|
export const __CLIENT_JS = {
|
package/src/server.test.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
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 () => {
|
|
6
|
-
const route = createRoute().get((context) => {
|
|
6
|
+
const route = createRoute().get((context: HS.Context) => {
|
|
7
7
|
return context.res.html('<h1>Hello World</h1>');
|
|
8
8
|
});
|
|
9
9
|
|
|
@@ -16,24 +16,25 @@ test('route fetch() returns a Response', async () => {
|
|
|
16
16
|
});
|
|
17
17
|
|
|
18
18
|
test('server with two routes can return Response from one', async () => {
|
|
19
|
-
const server = createServer({
|
|
19
|
+
const server = await createServer({
|
|
20
20
|
appDir: './app',
|
|
21
|
-
|
|
21
|
+
publicDir: './public',
|
|
22
|
+
plugins: [],
|
|
22
23
|
});
|
|
23
24
|
|
|
24
25
|
// Add two routes to the server
|
|
25
|
-
server.get('/users', (context) => {
|
|
26
|
+
server.get('/users', (context: HS.Context) => {
|
|
26
27
|
return context.res.html('<h1>Users Page</h1>');
|
|
27
28
|
});
|
|
28
29
|
|
|
29
|
-
server.get('/posts', (context) => {
|
|
30
|
+
server.get('/posts', (context: HS.Context) => {
|
|
30
31
|
return context.res.html('<h1>Posts Page</h1>');
|
|
31
32
|
});
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
// Test that we can get a Response from one of the routes
|
|
35
36
|
const request = new Request('http://localhost:3000/users');
|
|
36
|
-
const testRoute = server._routes.find((route) => route._path === '/users');
|
|
37
|
+
const testRoute = server._routes.find((route: HS.Route) => route._path() === '/users');
|
|
37
38
|
const response = await testRoute!.fetch(request);
|
|
38
39
|
|
|
39
40
|
expect(response).toBeInstanceOf(Response);
|
|
@@ -42,22 +43,23 @@ test('server with two routes can return Response from one', async () => {
|
|
|
42
43
|
});
|
|
43
44
|
|
|
44
45
|
test('server returns a route with a POST request', async () => {
|
|
45
|
-
const server = createServer({
|
|
46
|
+
const server = await createServer({
|
|
46
47
|
appDir: './app',
|
|
47
|
-
|
|
48
|
+
publicDir: './public',
|
|
49
|
+
plugins: [],
|
|
48
50
|
});
|
|
49
51
|
|
|
50
52
|
// Add two routes to the server
|
|
51
|
-
server.get('/users', (context) => {
|
|
53
|
+
server.get('/users', (context: HS.Context) => {
|
|
52
54
|
return context.res.html('<h1>GET /users</h1>');
|
|
53
55
|
});
|
|
54
56
|
|
|
55
|
-
server.post('/users', (context) => {
|
|
57
|
+
server.post('/users', (context: HS.Context) => {
|
|
56
58
|
return context.res.html('<h1>POST /users</h1>');
|
|
57
59
|
});
|
|
58
60
|
|
|
59
|
-
const route = server._routes.find((route) => route._path === '/users' && route._methods().includes('POST')) as HS.Route;
|
|
60
|
-
const request = new Request('http://localhost:3000/', { method: 'POST' });
|
|
61
|
+
const route = server._routes.find((route: HS.Route) => route._path() === '/users' && route._methods().includes('POST')) as HS.Route;
|
|
62
|
+
const request = new Request('http://localhost:3000/users', { method: 'POST' });
|
|
61
63
|
const response = await route.fetch(request);
|
|
62
64
|
|
|
63
65
|
expect(response).toBeInstanceOf(Response);
|
|
@@ -66,18 +68,19 @@ test('server returns a route with a POST request', async () => {
|
|
|
66
68
|
});
|
|
67
69
|
|
|
68
70
|
test('returns 405 when route path matches but HTTP method does not', async () => {
|
|
69
|
-
const server = createServer({
|
|
71
|
+
const server = await createServer({
|
|
70
72
|
appDir: './app',
|
|
71
|
-
|
|
73
|
+
publicDir: './public',
|
|
74
|
+
plugins: [],
|
|
72
75
|
});
|
|
73
76
|
|
|
74
77
|
// Route registered for GET only
|
|
75
|
-
server.get('/users', (context) => {
|
|
78
|
+
server.get('/users', (context: HS.Context) => {
|
|
76
79
|
return context.res.html('<h1>Users Page</h1>');
|
|
77
80
|
});
|
|
78
81
|
|
|
79
82
|
// Attempt to POST to /users, which should return 405
|
|
80
|
-
const route = server._routes.find((route) => route._path === '/users')!;
|
|
83
|
+
const route = server._routes.find((route: HS.Route) => route._path() === '/users')!;
|
|
81
84
|
const request = new Request('http://localhost:3000/users', { method: 'POST' });
|
|
82
85
|
const response = await route.fetch(request);
|
|
83
86
|
|
|
@@ -86,3 +89,124 @@ test('returns 405 when route path matches but HTTP method does not', async () =>
|
|
|
86
89
|
const text = await response.text();
|
|
87
90
|
expect(text).toContain('Method not allowed');
|
|
88
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,11 +1,12 @@
|
|
|
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';
|
|
5
|
+
import { Cookies } from './cookies';
|
|
6
|
+
|
|
7
|
+
import type { Hyperspan as HS } from './types';
|
|
6
8
|
|
|
7
9
|
export const IS_PROD = process.env.NODE_ENV === 'production';
|
|
8
|
-
const CWD = process.cwd();
|
|
9
10
|
|
|
10
11
|
export class HTTPException extends Error {
|
|
11
12
|
constructor(public status: number, message?: string) {
|
|
@@ -37,10 +38,25 @@ 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 {
|
|
55
|
+
vars: {},
|
|
41
56
|
route: {
|
|
42
57
|
path,
|
|
43
58
|
params: params,
|
|
59
|
+
cssImports: route ? route._config.cssImports ?? [] : [],
|
|
44
60
|
},
|
|
45
61
|
req: {
|
|
46
62
|
raw: req,
|
|
@@ -48,16 +64,23 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
48
64
|
method,
|
|
49
65
|
headers,
|
|
50
66
|
query,
|
|
51
|
-
|
|
67
|
+
cookies: new Cookies(req),
|
|
68
|
+
async text() { return req.text() },
|
|
69
|
+
async json<T = unknown>() { return await req.json() as T },
|
|
70
|
+
async formData<T = unknown>() { return await req.formData() as T },
|
|
71
|
+
async urlencoded() { return new URLSearchParams(await req.text()) },
|
|
52
72
|
},
|
|
53
73
|
res: {
|
|
74
|
+
cookies: new Cookies(req, headers),
|
|
75
|
+
headers,
|
|
54
76
|
raw: new Response(),
|
|
55
|
-
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 } }),
|
|
56
|
-
json: (json: any, options?: { status?: number; headers?: Headers | Record<string, string> }) => new Response(JSON.stringify(json), { ...options, headers: { 'Content-Type': 'application/json', ...options?.headers } }),
|
|
57
|
-
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 } }),
|
|
58
|
-
redirect: (url: string, options?: { status?: number; headers?: Headers | Record<string, string> }) => new Response(null, { status: 302, headers: { Location: url, ...options?.headers } }),
|
|
59
|
-
error: (error: Error, options?: { status?: number; headers?: Headers | Record<string, string> }) => new Response(error.message, { status: 500, ...options }),
|
|
60
|
-
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,
|
|
61
84
|
},
|
|
62
85
|
};
|
|
63
86
|
}
|
|
@@ -73,7 +96,6 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
|
73
96
|
|
|
74
97
|
const api: HS.Route = {
|
|
75
98
|
_kind: 'hsRoute',
|
|
76
|
-
_name: config.name,
|
|
77
99
|
_config: config,
|
|
78
100
|
_methods: () => Object.keys(_handlers),
|
|
79
101
|
_path() {
|
|
@@ -132,6 +154,10 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
|
132
154
|
_middleware['OPTIONS'] = handlerOptions?.middleware || [];
|
|
133
155
|
return api;
|
|
134
156
|
},
|
|
157
|
+
errorHandler(handler: HS.RouteHandler) {
|
|
158
|
+
_handlers['_ERROR'] = handler;
|
|
159
|
+
return api;
|
|
160
|
+
},
|
|
135
161
|
/**
|
|
136
162
|
* Add middleware specific to this route
|
|
137
163
|
*/
|
|
@@ -145,12 +171,11 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
|
145
171
|
*/
|
|
146
172
|
async fetch(request: Request) {
|
|
147
173
|
const context = createContext(request, api);
|
|
174
|
+
const method = context.req.method;
|
|
148
175
|
const globalMiddleware = _middleware['*'] || [];
|
|
149
|
-
const methodMiddleware = _middleware[
|
|
176
|
+
const methodMiddleware = _middleware[method] || [];
|
|
150
177
|
|
|
151
178
|
const methodHandler = async (context: HS.Context) => {
|
|
152
|
-
const method = context.req.method;
|
|
153
|
-
|
|
154
179
|
// Handle CORS preflight requests (if no OPTIONS handler is defined)
|
|
155
180
|
if (method === 'OPTIONS' && !_handlers['OPTIONS']) {
|
|
156
181
|
return context.res.html(
|
|
@@ -194,7 +219,16 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
|
194
219
|
return routeContent;
|
|
195
220
|
};
|
|
196
221
|
|
|
197
|
-
|
|
222
|
+
// Run the route handler and any middleware
|
|
223
|
+
// If an error occurs, run the error handler if it exists
|
|
224
|
+
try {
|
|
225
|
+
return await executeMiddleware(context, [...globalMiddleware, ...methodMiddleware, methodHandler]);
|
|
226
|
+
} catch (e) {
|
|
227
|
+
if (_handlers['_ERROR']) {
|
|
228
|
+
return await (_handlers['_ERROR'](context) as Promise<Response>);
|
|
229
|
+
}
|
|
230
|
+
throw e;
|
|
231
|
+
}
|
|
198
232
|
},
|
|
199
233
|
};
|
|
200
234
|
|
|
@@ -327,11 +361,6 @@ export function getRunnableRoute(route: unknown, routeConfig?: HS.RouteConfig):
|
|
|
327
361
|
|
|
328
362
|
const kind = typeof route;
|
|
329
363
|
|
|
330
|
-
// Plain function - wrap in createRoute()
|
|
331
|
-
if (kind === 'function') {
|
|
332
|
-
return createRoute(routeConfig).get(route as HS.RouteHandler);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
364
|
// Module - get default and use it
|
|
336
365
|
// @ts-ignore
|
|
337
366
|
if (kind === 'object' && 'default' in route) {
|
|
@@ -353,17 +382,7 @@ export function isRunnableRoute(route: unknown): boolean {
|
|
|
353
382
|
}
|
|
354
383
|
|
|
355
384
|
const obj = route as { _kind: string; fetch: (request: Request) => Promise<Response> };
|
|
356
|
-
return
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* Is valid route path to add to server?
|
|
361
|
-
*/
|
|
362
|
-
export function isValidRoutePath(path: string): boolean {
|
|
363
|
-
const isHiddenRoute = path.includes('/__');
|
|
364
|
-
const isTestFile = path.includes('.test') || path.includes('.spec');
|
|
365
|
-
|
|
366
|
-
return !isHiddenRoute && !isTestFile;
|
|
385
|
+
return typeof obj?._kind === 'string' && 'fetch' in obj;
|
|
367
386
|
}
|
|
368
387
|
|
|
369
388
|
/**
|
|
@@ -461,53 +480,3 @@ export function createReadableStreamFromAsyncGenerator(output: AsyncGenerator) {
|
|
|
461
480
|
},
|
|
462
481
|
});
|
|
463
482
|
}
|
|
464
|
-
|
|
465
|
-
/**
|
|
466
|
-
* Normalize URL path
|
|
467
|
-
* Removes trailing slash and lowercases path
|
|
468
|
-
*/
|
|
469
|
-
const ROUTE_SEGMENT_REGEX = /(\[[a-zA-Z_\.]+\])/g;
|
|
470
|
-
export function parsePath(urlPath: string): { path: string, params: string[] } {
|
|
471
|
-
const params: string[] = [];
|
|
472
|
-
urlPath = urlPath.replace('index', '').replace('.ts', '').replace('.js', '');
|
|
473
|
-
|
|
474
|
-
if (urlPath.startsWith('/')) {
|
|
475
|
-
urlPath = urlPath.substring(1);
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
if (urlPath.endsWith('/')) {
|
|
479
|
-
urlPath = urlPath.substring(0, urlPath.length - 1);
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
if (!urlPath) {
|
|
483
|
-
return { path: '/', params: [] };
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// Dynamic params
|
|
487
|
-
if (ROUTE_SEGMENT_REGEX.test(urlPath)) {
|
|
488
|
-
urlPath = urlPath.replace(ROUTE_SEGMENT_REGEX, (match: string) => {
|
|
489
|
-
const paramName = match.replace(/[^a-zA-Z_\.]+/g, '');
|
|
490
|
-
params.push(paramName);
|
|
491
|
-
|
|
492
|
-
if (match.includes('...')) {
|
|
493
|
-
return '*';
|
|
494
|
-
} else {
|
|
495
|
-
return ':' + paramName;
|
|
496
|
-
}
|
|
497
|
-
});
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
// Only lowercase non-param segments (do not lowercase after ':')
|
|
501
|
-
return {
|
|
502
|
-
path: (
|
|
503
|
-
'/' +
|
|
504
|
-
urlPath
|
|
505
|
-
.split('/')
|
|
506
|
-
.map((segment) =>
|
|
507
|
-
segment.startsWith(':') || segment === '*' ? segment : segment.toLowerCase()
|
|
508
|
-
)
|
|
509
|
-
.join('/')
|
|
510
|
-
),
|
|
511
|
-
params,
|
|
512
|
-
};
|
|
513
|
-
}
|