@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/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
@@ -0,0 +1,2 @@
1
+ export { createConfig, createContext, createRoute, createServer, getRunnableRoute, StreamResponse, IS_PROD, HTTPException } from './server';
2
+ export type { Hyperspan } from './types';
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/assets';
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 = {
@@ -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
- staticFileRoot: './public',
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
- staticFileRoot: './public',
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
- staticFileRoot: './public',
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
- export type { HS as Hyperspan };
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
- body: req.body,
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[context.req.method] || [];
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
- return executeMiddleware(context, [...globalMiddleware, ...methodMiddleware, methodHandler]);
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 'hsRoute' === obj?._kind && 'fetch' in obj;
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
- }