@hyperspan/framework 1.0.0-alpha.8 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -6
- package/src/actions.test.ts +24 -83
- package/src/actions.ts +12 -7
- package/src/client/_hs/hyperspan-streaming.client.ts +36 -64
- package/src/client/js.test.ts +200 -0
- package/src/client/js.ts +76 -43
- package/src/cookies.ts +234 -0
- package/src/index.ts +1 -1
- package/src/layout.ts +24 -1
- package/src/middleware.ts +87 -1
- package/src/server.test.ts +116 -1
- package/src/server.ts +101 -41
- package/src/types.ts +83 -32
- package/src/utils.test.ts +196 -0
- package/src/plugins.ts +0 -94
package/src/client/js.ts
CHANGED
|
@@ -1,61 +1,94 @@
|
|
|
1
1
|
import { html } from '@hyperspan/html';
|
|
2
|
+
import { assetHash as assetHashFn } from '../utils';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type { Hyperspan as HS } from '../types';
|
|
5
|
+
|
|
6
|
+
const CWD = process.cwd();
|
|
7
|
+
const IS_PROD = process.env.NODE_ENV === 'production';
|
|
2
8
|
|
|
3
9
|
export const JS_PUBLIC_PATH = '/_hs/js';
|
|
4
10
|
export const JS_ISLAND_PUBLIC_PATH = '/_hs/js/islands';
|
|
5
11
|
export const JS_IMPORT_MAP = new Map<string, string>();
|
|
12
|
+
const CLIENT_JS_CACHE = new Map<string, { esmName: string, exports: string, fnArgs: string, publicPath: string }>();
|
|
13
|
+
const EXPORT_REGEX = /export\{(.*)\}/g;
|
|
6
14
|
|
|
7
15
|
/**
|
|
8
|
-
*
|
|
16
|
+
* Build a client JS module and return a Hyperspan.ClientJSBuildResult object
|
|
9
17
|
*/
|
|
10
|
-
export function
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
throw new Error(
|
|
14
|
-
`[Hyperspan] Client JS was not loaded by Hyperspan! Ensure the filename ends with .client.ts to use this render method.`
|
|
15
|
-
);
|
|
16
|
-
}
|
|
18
|
+
export async function buildClientJS(modulePathResolved: string): Promise<HS.ClientJSBuildResult> {
|
|
19
|
+
const modulePath = modulePathResolved.replace('file://', '');
|
|
20
|
+
const assetHash = assetHashFn(modulePath);
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
module
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
// Cache: Avoid re-processing the same file
|
|
23
|
+
if (!CLIENT_JS_CACHE.has(assetHash)) {
|
|
24
|
+
// Build the client JS module
|
|
25
|
+
const result = await Bun.build({
|
|
26
|
+
entrypoints: [modulePath],
|
|
27
|
+
outdir: join(CWD, './public', JS_PUBLIC_PATH), // @TODO: Make this configurable... should be read from config file...
|
|
28
|
+
naming: IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined,
|
|
29
|
+
external: Array.from(JS_IMPORT_MAP.keys()),
|
|
30
|
+
minify: true,
|
|
31
|
+
format: 'esm',
|
|
32
|
+
target: 'browser',
|
|
33
|
+
env: 'APP_PUBLIC_*',
|
|
34
|
+
});
|
|
29
35
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
// Add output file to import map
|
|
37
|
+
const esmName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', '');
|
|
38
|
+
const publicPath = `${JS_PUBLIC_PATH}/${esmName}.js`;
|
|
39
|
+
JS_IMPORT_MAP.set(esmName, publicPath);
|
|
40
|
+
|
|
41
|
+
// Get the contents of the file to extract the exports
|
|
42
|
+
const contents = await result.outputs[0].text();
|
|
43
|
+
const exportLine = EXPORT_REGEX.exec(contents);
|
|
44
|
+
|
|
45
|
+
let exports = '{}';
|
|
46
|
+
if (exportLine) {
|
|
47
|
+
const exportName = exportLine[1];
|
|
48
|
+
exports =
|
|
49
|
+
'{' +
|
|
50
|
+
exportName
|
|
51
|
+
.split(',')
|
|
52
|
+
.map((name) => name.trim().split(' as '))
|
|
53
|
+
.map(([name, alias]) => `${alias === 'default' ? 'default as ' + name : alias}`)
|
|
54
|
+
.join(', ') +
|
|
55
|
+
'}';
|
|
43
56
|
}
|
|
57
|
+
const fnArgs = exports.replace(/(\w+)\s*as\s*(\w+)/g, '$1: $2');
|
|
58
|
+
CLIENT_JS_CACHE.set(assetHash, { esmName, exports, fnArgs, publicPath });
|
|
44
59
|
}
|
|
45
60
|
|
|
46
|
-
const
|
|
47
|
-
const firstLine = lines[0];
|
|
48
|
-
const lastLine = lines[lines.length - 1];
|
|
61
|
+
const { esmName, exports, fnArgs, publicPath } = CLIENT_JS_CACHE.get(assetHash)!;
|
|
49
62
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
63
|
+
return {
|
|
64
|
+
assetHash,
|
|
65
|
+
esmName,
|
|
66
|
+
publicPath,
|
|
67
|
+
renderScriptTag: (loadScript) => {
|
|
68
|
+
const t = typeof loadScript;
|
|
69
|
+
|
|
70
|
+
if (t === 'string') {
|
|
71
|
+
return html`
|
|
72
|
+
<script type="module" data-source-id="${assetHash}">import ${exports} from "${esmName}";\n(${html.raw(loadScript as string)})(${fnArgs});</script>
|
|
73
|
+
`;
|
|
74
|
+
}
|
|
75
|
+
if (t === 'function') {
|
|
76
|
+
return html`
|
|
77
|
+
<script type="module" data-source-id="${assetHash}">import ${exports} from "${esmName}";\n(${html.raw(functionToString(loadScript))})(${fnArgs});</script>
|
|
78
|
+
`;
|
|
79
|
+
}
|
|
54
80
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
81
|
+
return html`
|
|
82
|
+
<script type="module" data-source-id="${assetHash}">import "${esmName}";</script>
|
|
83
|
+
`;
|
|
84
|
+
}
|
|
58
85
|
}
|
|
86
|
+
}
|
|
59
87
|
|
|
60
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Convert a function to a string (results in loss of context!)
|
|
90
|
+
* Handles named, async, and arrow functions
|
|
91
|
+
*/
|
|
92
|
+
export function functionToString(fn: any) {
|
|
93
|
+
return fn.toString().trim();
|
|
61
94
|
}
|
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
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { createConfig, createContext, createRoute, createServer, getRunnableRoute, StreamResponse, IS_PROD,
|
|
1
|
+
export { createConfig, createContext, createRoute, createServer, getRunnableRoute, StreamResponse, IS_PROD, HTTPResponseException } from './server';
|
|
2
2
|
export type { Hyperspan } from './types';
|
package/src/layout.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { html } from '@hyperspan/html';
|
|
2
|
-
import { JS_IMPORT_MAP } from './client/js';
|
|
2
|
+
import { JS_IMPORT_MAP, buildClientJS } from './client/js';
|
|
3
3
|
import { CSS_PUBLIC_PATH, CSS_ROUTE_MAP } from './client/css';
|
|
4
4
|
import type { Hyperspan as HS } from './types';
|
|
5
5
|
|
|
6
|
+
const clientStreamingJS = await buildClientJS(import.meta.resolve('./client/_hs/hyperspan-streaming.client'));
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* Output the importmap for the client so we can use ESModules on the client to load JS files on demand
|
|
8
10
|
*/
|
|
@@ -11,6 +13,27 @@ export function hyperspanScriptTags() {
|
|
|
11
13
|
<script type="importmap">
|
|
12
14
|
{"imports": ${Object.fromEntries(JS_IMPORT_MAP)}}
|
|
13
15
|
</script>
|
|
16
|
+
<script id="hyperspan-streaming-script">
|
|
17
|
+
// [Hyperspan] Streaming - Load the client streaming JS module only when the first chunk is loaded
|
|
18
|
+
window._hsc = window._hsc || [];
|
|
19
|
+
var hscc = function(e) {
|
|
20
|
+
if (window._hscc !== undefined) {
|
|
21
|
+
window._hscc(e);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
window._hsc.push = function(e) {
|
|
25
|
+
Array.prototype.push.call(window._hsc, e);
|
|
26
|
+
if (window._hsc.length === 1) {
|
|
27
|
+
const script = document.createElement('script');
|
|
28
|
+
script.src = "${clientStreamingJS.publicPath}";
|
|
29
|
+
document.body.appendChild(script);
|
|
30
|
+
script.onload = function() {
|
|
31
|
+
hscc(e);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
hscc(e);
|
|
35
|
+
};
|
|
36
|
+
</script>
|
|
14
37
|
`;
|
|
15
38
|
}
|
|
16
39
|
|
package/src/middleware.ts
CHANGED
|
@@ -1,4 +1,91 @@
|
|
|
1
|
+
import { formDataToJSON } from './utils';
|
|
2
|
+
import { z, flattenError } from 'zod/v4';
|
|
3
|
+
|
|
4
|
+
import type { ZodAny, ZodObject, ZodError } from 'zod/v4';
|
|
1
5
|
import type { Hyperspan as HS } from './types';
|
|
6
|
+
import { HTTPResponseException } from './server';
|
|
7
|
+
|
|
8
|
+
export type TValidationType = 'json' | 'form' | 'urlencoded';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Infer the validation type from the request Content-Type header
|
|
12
|
+
*/
|
|
13
|
+
function inferValidationType(headers: Headers): TValidationType {
|
|
14
|
+
const contentType = headers.get('content-type')?.toLowerCase() || '';
|
|
15
|
+
|
|
16
|
+
if (contentType.includes('application/json')) {
|
|
17
|
+
return 'json';
|
|
18
|
+
} else if (contentType.includes('multipart/form-data')) {
|
|
19
|
+
return 'form';
|
|
20
|
+
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
21
|
+
return 'urlencoded';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Default to json if content-type is not recognized
|
|
25
|
+
return 'json';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class ZodValidationError extends Error {
|
|
29
|
+
constructor(flattened: ReturnType<typeof flattenError>) {
|
|
30
|
+
super('Input validation error(s)');
|
|
31
|
+
this.name = 'ZodValidationError';
|
|
32
|
+
|
|
33
|
+
// Copy all properties from flattened error
|
|
34
|
+
Object.assign(this, flattened);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function validateQuery(schema: ZodObject | ZodAny): HS.MiddlewareFunction {
|
|
39
|
+
return async (context: HS.Context, next: HS.NextFunction) => {
|
|
40
|
+
const query = formDataToJSON(context.req.query);
|
|
41
|
+
const validated = schema.safeParse(query);
|
|
42
|
+
|
|
43
|
+
if (!validated.success) {
|
|
44
|
+
const err = formatZodError(validated.error);
|
|
45
|
+
return context.res.error(err, { status: 400 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Store the validated query in the context variables
|
|
49
|
+
context.vars.query = validated.data as z.infer<typeof schema>;
|
|
50
|
+
|
|
51
|
+
return next();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function validateBody(schema: ZodObject | ZodAny, type?: TValidationType): HS.MiddlewareFunction {
|
|
56
|
+
return async (context: HS.Context, next: HS.NextFunction) => {
|
|
57
|
+
// Infer type from Content-Type header if not provided
|
|
58
|
+
const validationType = type || inferValidationType(context.req.headers);
|
|
59
|
+
|
|
60
|
+
let body: unknown = {};
|
|
61
|
+
if (validationType === 'json') {
|
|
62
|
+
body = await context.req.raw.json();
|
|
63
|
+
} else if (validationType === 'form') {
|
|
64
|
+
const formData = await context.req.formData();
|
|
65
|
+
body = formDataToJSON(formData as FormData);
|
|
66
|
+
} else if (validationType === 'urlencoded') {
|
|
67
|
+
const urlencoded = await context.req.urlencoded();
|
|
68
|
+
body = formDataToJSON(urlencoded);
|
|
69
|
+
}
|
|
70
|
+
const validated = schema.safeParse(body);
|
|
71
|
+
|
|
72
|
+
if (!validated.success) {
|
|
73
|
+
const err = formatZodError(validated.error);
|
|
74
|
+
throw new HTTPResponseException(err, { status: 400 });
|
|
75
|
+
//return context.res.error(err, { status: 400 });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Store the validated body in the context variables
|
|
79
|
+
context.vars.body = validated.data as z.infer<typeof schema>;
|
|
80
|
+
|
|
81
|
+
return next();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function formatZodError(error: ZodError): ZodValidationError {
|
|
86
|
+
const zodError = flattenError(error);
|
|
87
|
+
return new ZodValidationError(zodError);
|
|
88
|
+
}
|
|
2
89
|
|
|
3
90
|
/**
|
|
4
91
|
* Type guard to check if a handler is a middleware function
|
|
@@ -55,4 +142,3 @@ export async function executeMiddleware(
|
|
|
55
142
|
// Start execution from the first handler
|
|
56
143
|
return await createNext(0)();
|
|
57
144
|
}
|
|
58
|
-
|
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,118 @@ 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 should NOT 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('newCookie=newValue');
|
|
129
|
+
|
|
130
|
+
// Test deleting a cookie
|
|
131
|
+
context.res.cookies.delete('sessionId');
|
|
132
|
+
setCookieHeader = context.res.headers.get('Set-Cookie');
|
|
133
|
+
expect(setCookieHeader).toBeTruthy();
|
|
134
|
+
if (setCookieHeader) {
|
|
135
|
+
expect(setCookieHeader).toContain('sessionId=');
|
|
136
|
+
expect(setCookieHeader).toContain('Expires=');
|
|
137
|
+
// Verify it's set to expire in the past (deleted)
|
|
138
|
+
const expiresMatch = setCookieHeader.match(/Expires=([^;]+)/);
|
|
139
|
+
expect(expiresMatch).toBeTruthy();
|
|
140
|
+
if (expiresMatch) {
|
|
141
|
+
const expiresDate = new Date(expiresMatch[1]);
|
|
142
|
+
expect(expiresDate.getTime()).toBeLessThanOrEqual(new Date(0).getTime());
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('createContext() merge() function preserves custom headers when using response methods', () => {
|
|
148
|
+
// Create a request
|
|
149
|
+
const request = new Request('http://localhost:3000/');
|
|
150
|
+
|
|
151
|
+
// Create context from the request
|
|
152
|
+
const context = createContext(request);
|
|
153
|
+
|
|
154
|
+
// Set custom headers on the context response
|
|
155
|
+
context.res.headers.set('X-Custom-Header', 'custom-value');
|
|
156
|
+
context.res.headers.set('X-Another-Header', 'another-value');
|
|
157
|
+
context.res.headers.set('Authorization', 'Bearer token123');
|
|
158
|
+
|
|
159
|
+
// Use html() method which should merge headers
|
|
160
|
+
const response = context.res.html('<h1>Test</h1>');
|
|
161
|
+
|
|
162
|
+
// Verify the response has both the custom headers and the Content-Type header
|
|
163
|
+
expect(response.headers.get('X-Custom-Header')).toBe('custom-value');
|
|
164
|
+
expect(response.headers.get('X-Another-Header')).toBe('another-value');
|
|
165
|
+
expect(response.headers.get('Authorization')).toBe('Bearer token123');
|
|
166
|
+
expect(response.headers.get('Content-Type')).toBe('text/html; charset=UTF-8');
|
|
167
|
+
|
|
168
|
+
// Verify response body is correct
|
|
169
|
+
expect(response.status).toBe(200);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('createContext() merge() function preserves custom headers with json() method', () => {
|
|
173
|
+
const request = new Request('http://localhost:3000/');
|
|
174
|
+
const context = createContext(request);
|
|
175
|
+
|
|
176
|
+
// Set custom headers
|
|
177
|
+
context.res.headers.set('X-API-Version', 'v1');
|
|
178
|
+
context.res.headers.set('X-Request-ID', 'req-123');
|
|
179
|
+
|
|
180
|
+
// Use json() method
|
|
181
|
+
const response = context.res.json({ message: 'Hello' });
|
|
182
|
+
|
|
183
|
+
// Verify headers are merged
|
|
184
|
+
expect(response.headers.get('X-API-Version')).toBe('v1');
|
|
185
|
+
expect(response.headers.get('X-Request-ID')).toBe('req-123');
|
|
186
|
+
expect(response.headers.get('Content-Type')).toBe('application/json');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('createContext() merge() function allows response headers to override context headers', () => {
|
|
190
|
+
const request = new Request('http://localhost:3000/');
|
|
191
|
+
const context = createContext(request);
|
|
192
|
+
|
|
193
|
+
// Set a header on context
|
|
194
|
+
context.res.headers.set('X-Header', 'context-value');
|
|
195
|
+
|
|
196
|
+
// Use html() with options that include the same header (should override)
|
|
197
|
+
const response = context.res.html('<h1>Test</h1>', {
|
|
198
|
+
headers: {
|
|
199
|
+
'X-Header': 'response-value',
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Response header should override context header
|
|
204
|
+
expect(response.headers.get('X-Header')).toBe('response-value');
|
|
205
|
+
expect(response.headers.get('Content-Type')).toBe('text/html; charset=UTF-8');
|
|
206
|
+
});
|