@hyperspan/framework 0.5.5 → 1.0.0-alpha.1
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 +34 -26
- package/src/callsites.ts +17 -0
- package/src/client/css.ts +2 -0
- package/src/client/js.ts +82 -0
- package/src/layout.ts +31 -0
- package/src/middleware.ts +53 -14
- package/src/plugins.ts +64 -58
- package/src/server.test.ts +88 -0
- package/src/server.ts +294 -427
- package/src/types.ts +110 -0
- package/src/utils.ts +5 -0
- package/build.ts +0 -16
- package/dist/assets.js +0 -120
- package/dist/chunk-atw8cdg1.js +0 -19
- package/dist/middleware.js +0 -179
- package/dist/server.js +0 -2266
- package/src/actions.test.ts +0 -106
- package/src/actions.ts +0 -256
- package/src/assets.ts +0 -176
package/src/server.ts
CHANGED
|
@@ -1,129 +1,200 @@
|
|
|
1
|
-
import { buildClientJS, buildClientCSS, assetHash } from './assets';
|
|
2
|
-
import { clientJSPlugin } from './plugins';
|
|
3
1
|
import { HSHtml, html, isHSHtml, renderStream, renderAsync, render } from '@hyperspan/html';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
import { serveStatic } from 'hono/bun';
|
|
9
|
-
import { HTTPException } from 'hono/http-exception';
|
|
10
|
-
import { csrf } from 'hono/csrf';
|
|
11
|
-
|
|
12
|
-
import type { HandlerResponse, MiddlewareHandler } from 'hono/types';
|
|
13
|
-
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
|
2
|
+
import { executeMiddleware } from './middleware';
|
|
3
|
+
import type { Hyperspan as HS } from './types';
|
|
4
|
+
import { clientJSPlugin } from './plugins';
|
|
5
|
+
export type { HS as Hyperspan };
|
|
14
6
|
|
|
15
7
|
export const IS_PROD = process.env.NODE_ENV === 'production';
|
|
16
8
|
const CWD = process.cwd();
|
|
17
9
|
|
|
10
|
+
export class HTTPException extends Error {
|
|
11
|
+
constructor(public status: number, message?: string) {
|
|
12
|
+
super(message);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
18
16
|
/**
|
|
19
|
-
*
|
|
17
|
+
* Ensures a valid config object is returned, even with an empty object or partial object passed in
|
|
20
18
|
*/
|
|
21
|
-
export
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
get: (handler: THSRouteHandler) => THSRoute;
|
|
29
|
-
post: (handler: THSRouteHandler) => THSRoute;
|
|
30
|
-
middleware: (middleware: Array<MiddlewareHandler>) => THSRoute;
|
|
31
|
-
_getRouteHandlers: () => Array<
|
|
32
|
-
MiddlewareHandler | ((context: THSContext) => HandlerResponse<any>)
|
|
33
|
-
>;
|
|
34
|
-
};
|
|
35
|
-
export type THSAPIRoute = {
|
|
36
|
-
_kind: 'hsAPIRoute';
|
|
37
|
-
get: (handler: THSAPIRouteHandler) => THSAPIRoute;
|
|
38
|
-
post: (handler: THSAPIRouteHandler) => THSAPIRoute;
|
|
39
|
-
put: (handler: THSAPIRouteHandler) => THSAPIRoute;
|
|
40
|
-
delete: (handler: THSAPIRouteHandler) => THSAPIRoute;
|
|
41
|
-
patch: (handler: THSAPIRouteHandler) => THSAPIRoute;
|
|
42
|
-
middleware: (middleware: Array<MiddlewareHandler>) => THSAPIRoute;
|
|
43
|
-
_getRouteHandlers: () => Array<
|
|
44
|
-
MiddlewareHandler | ((context: THSContext) => HandlerResponse<any>)
|
|
45
|
-
>;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
export function createConfig(config: THSServerConfig): THSServerConfig {
|
|
49
|
-
return config;
|
|
19
|
+
export function createConfig(config: Partial<HS.Config> = {}): HS.Config {
|
|
20
|
+
return {
|
|
21
|
+
...config,
|
|
22
|
+
appDir: config.appDir ?? './app',
|
|
23
|
+
publicDir: config.publicDir ?? './public',
|
|
24
|
+
plugins: [clientJSPlugin(), ...(config.plugins ?? [])],
|
|
25
|
+
};
|
|
50
26
|
}
|
|
51
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Creates a context object for a request
|
|
30
|
+
*/
|
|
31
|
+
export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
32
|
+
const url = new URL(req.url);
|
|
33
|
+
const query = new URLSearchParams(url.search);
|
|
34
|
+
const method = req.method.toUpperCase();
|
|
35
|
+
const headers = new Headers(req.headers);
|
|
36
|
+
const path = route?._path() || '/';
|
|
37
|
+
// @ts-ignore - Bun will put 'params' on the Request object even though it's not standardized
|
|
38
|
+
const params: HS.RouteParamsParser<path> = req?.params || {};
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
route: {
|
|
42
|
+
path,
|
|
43
|
+
params: params,
|
|
44
|
+
},
|
|
45
|
+
req: {
|
|
46
|
+
raw: req,
|
|
47
|
+
url,
|
|
48
|
+
method,
|
|
49
|
+
headers,
|
|
50
|
+
query,
|
|
51
|
+
body: req.body,
|
|
52
|
+
},
|
|
53
|
+
res: {
|
|
54
|
+
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 }),
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
52
66
|
/**
|
|
53
67
|
* Define a route that can handle a direct HTTP request.
|
|
54
68
|
* Route handlers should return a HSHtml or Response object
|
|
55
69
|
*/
|
|
56
|
-
export function createRoute(
|
|
57
|
-
|
|
58
|
-
let _middleware: Array<
|
|
70
|
+
export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
71
|
+
const _handlers: Record<string, HS.RouteHandler> = {};
|
|
72
|
+
let _middleware: Record<string, Array<HS.MiddlewareFunction>> = { '*': [] };
|
|
59
73
|
|
|
60
|
-
|
|
61
|
-
_handlers['GET'] = handler;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const api: THSRoute = {
|
|
74
|
+
const api: HS.Route = {
|
|
65
75
|
_kind: 'hsRoute',
|
|
76
|
+
_name: config.name,
|
|
77
|
+
_config: config,
|
|
78
|
+
_methods: () => Object.keys(_handlers),
|
|
79
|
+
_path() {
|
|
80
|
+
if (this._config.path) {
|
|
81
|
+
const { path } = parsePath(this._config.path);
|
|
82
|
+
return path;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return '/';
|
|
86
|
+
},
|
|
66
87
|
/**
|
|
67
88
|
* Add a GET route handler (primary page display)
|
|
68
89
|
*/
|
|
69
|
-
get(handler:
|
|
90
|
+
get(handler: HS.RouteHandler, handlerOptions?: HS.RouteHandlerOptions) {
|
|
70
91
|
_handlers['GET'] = handler;
|
|
92
|
+
_middleware['GET'] = handlerOptions?.middleware || [];
|
|
71
93
|
return api;
|
|
72
94
|
},
|
|
73
95
|
/**
|
|
74
96
|
* Add a POST route handler (typically to process form data)
|
|
75
97
|
*/
|
|
76
|
-
post(handler:
|
|
98
|
+
post(handler: HS.RouteHandler, handlerOptions?: HS.RouteHandlerOptions) {
|
|
77
99
|
_handlers['POST'] = handler;
|
|
100
|
+
_middleware['POST'] = handlerOptions?.middleware || [];
|
|
101
|
+
return api;
|
|
102
|
+
},
|
|
103
|
+
/**
|
|
104
|
+
* Add a PUT route handler (typically to update existing data)
|
|
105
|
+
*/
|
|
106
|
+
put(handler: HS.RouteHandler, handlerOptions?: HS.RouteHandlerOptions) {
|
|
107
|
+
_handlers['PUT'] = handler;
|
|
108
|
+
_middleware['PUT'] = handlerOptions?.middleware || [];
|
|
109
|
+
return api;
|
|
110
|
+
},
|
|
111
|
+
/**
|
|
112
|
+
* Add a DELETE route handler (typically to delete existing data)
|
|
113
|
+
*/
|
|
114
|
+
delete(handler: HS.RouteHandler, handlerOptions?: HS.RouteHandlerOptions) {
|
|
115
|
+
_handlers['DELETE'] = handler;
|
|
116
|
+
_middleware['DELETE'] = handlerOptions?.middleware || [];
|
|
117
|
+
return api;
|
|
118
|
+
},
|
|
119
|
+
/**
|
|
120
|
+
* Add a PATCH route handler (typically to update existing data)
|
|
121
|
+
*/
|
|
122
|
+
patch(handler: HS.RouteHandler, handlerOptions?: HS.RouteHandlerOptions) {
|
|
123
|
+
_handlers['PATCH'] = handler;
|
|
124
|
+
_middleware['PATCH'] = handlerOptions?.middleware || [];
|
|
125
|
+
return api;
|
|
126
|
+
},
|
|
127
|
+
/**
|
|
128
|
+
* Add a OPTIONS route handler (typically to handle CORS preflight requests)
|
|
129
|
+
*/
|
|
130
|
+
options(handler: HS.RouteHandler, handlerOptions?: HS.RouteHandlerOptions) {
|
|
131
|
+
_handlers['OPTIONS'] = handler;
|
|
132
|
+
_middleware['OPTIONS'] = handlerOptions?.middleware || [];
|
|
78
133
|
return api;
|
|
79
134
|
},
|
|
80
135
|
/**
|
|
81
136
|
* Add middleware specific to this route
|
|
82
137
|
*/
|
|
83
|
-
middleware(middleware: Array<
|
|
84
|
-
_middleware = middleware;
|
|
138
|
+
middleware(middleware: Array<HS.MiddlewareFunction>) {
|
|
139
|
+
_middleware['*'] = middleware;
|
|
85
140
|
return api;
|
|
86
141
|
},
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Fetch - handle a direct HTTP request
|
|
145
|
+
*/
|
|
146
|
+
async fetch(request: Request) {
|
|
147
|
+
const context = createContext(request, api);
|
|
148
|
+
const globalMiddleware = _middleware['*'] || [];
|
|
149
|
+
const methodMiddleware = _middleware[context.req.method] || [];
|
|
150
|
+
|
|
151
|
+
const methodHandler = async (context: HS.Context) => {
|
|
152
|
+
const method = context.req.method;
|
|
153
|
+
|
|
154
|
+
// Handle CORS preflight requests (if no OPTIONS handler is defined)
|
|
155
|
+
if (method === 'OPTIONS' && !_handlers['OPTIONS']) {
|
|
156
|
+
return context.res.html(
|
|
157
|
+
render(html`
|
|
97
158
|
<!DOCTYPE html>
|
|
98
159
|
<html lang="en"></html>
|
|
99
160
|
`),
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Handle other requests, HEAD is GET with no body
|
|
116
|
-
return returnHTMLResponse(context, () => {
|
|
117
|
-
const handler = method === 'HEAD' ? _handlers['GET'] : _handlers[method];
|
|
118
|
-
|
|
119
|
-
if (!handler) {
|
|
120
|
-
throw new HTTPException(405, { message: 'Method not allowed' });
|
|
161
|
+
{
|
|
162
|
+
status: 200,
|
|
163
|
+
headers: {
|
|
164
|
+
'Access-Control-Allow-Origin': '*',
|
|
165
|
+
'Access-Control-Allow-Methods': [
|
|
166
|
+
'HEAD',
|
|
167
|
+
'OPTIONS',
|
|
168
|
+
...Object.keys(_handlers),
|
|
169
|
+
].join(', '),
|
|
170
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
171
|
+
},
|
|
121
172
|
}
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const handler = method === 'HEAD' ? _handlers['GET'] : _handlers[method];
|
|
122
177
|
|
|
123
|
-
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
|
|
178
|
+
if (!handler) {
|
|
179
|
+
return context.res.error(new Error('Method not allowed'), { status: 405 });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// @TODO: Handle errors from route handler
|
|
183
|
+
const routeContent = await handler(context);
|
|
184
|
+
|
|
185
|
+
// Return Response if returned from route handler
|
|
186
|
+
if (routeContent instanceof Response) {
|
|
187
|
+
return routeContent;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (isHTMLContent(routeContent)) {
|
|
191
|
+
return returnHTMLResponse(context, () => routeContent);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return routeContent;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
return executeMiddleware(context, [...globalMiddleware, ...methodMiddleware, methodHandler]);
|
|
127
198
|
},
|
|
128
199
|
};
|
|
129
200
|
|
|
@@ -131,129 +202,84 @@ export function createRoute(handler?: THSRouteHandler): THSRoute {
|
|
|
131
202
|
}
|
|
132
203
|
|
|
133
204
|
/**
|
|
134
|
-
*
|
|
135
|
-
* API Route handlers should return a JSON object or a Response
|
|
205
|
+
* Creates a server object that can compose routes and middleware
|
|
136
206
|
*/
|
|
137
|
-
export function
|
|
138
|
-
|
|
139
|
-
|
|
207
|
+
export async function createServer(config: HS.Config = {} as HS.Config): Promise<HS.Server> {
|
|
208
|
+
const _middleware: HS.MiddlewareFunction[] = [];
|
|
209
|
+
const _routes: HS.Route[] = [];
|
|
140
210
|
|
|
141
|
-
if
|
|
142
|
-
|
|
211
|
+
// Load plugins, if any
|
|
212
|
+
if (config.plugins && config.plugins.length > 0) {
|
|
213
|
+
await Promise.all(config.plugins.map(plugin => plugin(config)));
|
|
143
214
|
}
|
|
144
215
|
|
|
145
|
-
const api:
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
216
|
+
const api: HS.Server = {
|
|
217
|
+
_config: config,
|
|
218
|
+
_routes: _routes,
|
|
219
|
+
_middleware: _middleware,
|
|
220
|
+
use(middleware: HS.MiddlewareFunction) {
|
|
221
|
+
_middleware.push(middleware);
|
|
222
|
+
return this;
|
|
150
223
|
},
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
224
|
+
get(path: string, handler: HS.RouteHandler, handlerOptions?: HS.RouteHandlerOptions) {
|
|
225
|
+
const route = createRoute().get(handler, handlerOptions);
|
|
226
|
+
route._config.path = path;
|
|
227
|
+
_routes.push(route);
|
|
228
|
+
return route;
|
|
154
229
|
},
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
230
|
+
post(path: string, handler: HS.RouteHandler, handlerOptions?: HS.RouteHandlerOptions) {
|
|
231
|
+
const route = createRoute().post(handler, handlerOptions);
|
|
232
|
+
route._config.path = path;
|
|
233
|
+
_routes.push(route);
|
|
234
|
+
return route;
|
|
158
235
|
},
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
236
|
+
put(path: string, handler: HS.RouteHandler, handlerOptions?: HS.RouteHandlerOptions) {
|
|
237
|
+
const route = createRoute().put(handler, handlerOptions);
|
|
238
|
+
route._config.path = path;
|
|
239
|
+
_routes.push(route);
|
|
240
|
+
return route;
|
|
162
241
|
},
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
242
|
+
delete(path: string, handler: HS.RouteHandler, handlerOptions?: HS.RouteHandlerOptions) {
|
|
243
|
+
const route = createRoute().delete(handler, handlerOptions);
|
|
244
|
+
route._config.path = path;
|
|
245
|
+
_routes.push(route);
|
|
246
|
+
return route;
|
|
166
247
|
},
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
248
|
+
patch(path: string, handler: HS.RouteHandler, handlerOptions?: HS.RouteHandlerOptions) {
|
|
249
|
+
const route = createRoute().patch(handler, handlerOptions);
|
|
250
|
+
route._config.path = path;
|
|
251
|
+
_routes.push(route);
|
|
252
|
+
return route;
|
|
170
253
|
},
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
// Handle CORS preflight requests
|
|
178
|
-
if (method === 'OPTIONS') {
|
|
179
|
-
return context.json(
|
|
180
|
-
{
|
|
181
|
-
meta: { success: true, dtResponse: new Date() },
|
|
182
|
-
data: {},
|
|
183
|
-
},
|
|
184
|
-
{
|
|
185
|
-
status: 200,
|
|
186
|
-
headers: {
|
|
187
|
-
'Access-Control-Allow-Origin': '*',
|
|
188
|
-
'Access-Control-Allow-Methods': [
|
|
189
|
-
'HEAD',
|
|
190
|
-
'OPTIONS',
|
|
191
|
-
...Object.keys(_handlers),
|
|
192
|
-
].join(', '),
|
|
193
|
-
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
194
|
-
},
|
|
195
|
-
}
|
|
196
|
-
);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Handle other requests, HEAD is GET with no body
|
|
200
|
-
const handler = method === 'HEAD' ? _handlers['GET'] : _handlers[method];
|
|
201
|
-
|
|
202
|
-
if (!handler) {
|
|
203
|
-
return context.json(
|
|
204
|
-
{
|
|
205
|
-
meta: { success: false, dtResponse: new Date() },
|
|
206
|
-
data: {},
|
|
207
|
-
error: {
|
|
208
|
-
message: 'Method not allowed',
|
|
209
|
-
},
|
|
210
|
-
},
|
|
211
|
-
{ status: 405 }
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
try {
|
|
216
|
-
const response = await handler(context);
|
|
217
|
-
|
|
218
|
-
if (response instanceof Response) {
|
|
219
|
-
return response;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
return context.json(
|
|
223
|
-
{ meta: { success: true, dtResponse: new Date() }, data: response },
|
|
224
|
-
{ status: 200 }
|
|
225
|
-
);
|
|
226
|
-
} catch (err) {
|
|
227
|
-
const e = err as Error;
|
|
228
|
-
!IS_PROD && console.error(e);
|
|
229
|
-
|
|
230
|
-
return context.json(
|
|
231
|
-
{
|
|
232
|
-
meta: { success: false, dtResponse: new Date() },
|
|
233
|
-
data: {},
|
|
234
|
-
error: {
|
|
235
|
-
message: e.message,
|
|
236
|
-
stack: IS_PROD ? undefined : e.stack?.split('\n'),
|
|
237
|
-
},
|
|
238
|
-
},
|
|
239
|
-
{ status: 500 }
|
|
240
|
-
);
|
|
241
|
-
}
|
|
242
|
-
},
|
|
243
|
-
];
|
|
254
|
+
options(path: string, handler: HS.RouteHandler, handlerOptions?: HS.RouteHandlerOptions) {
|
|
255
|
+
const route = createRoute().options(handler, handlerOptions);
|
|
256
|
+
route._config.path = path;
|
|
257
|
+
_routes.push(route);
|
|
258
|
+
return route;
|
|
244
259
|
},
|
|
245
260
|
};
|
|
246
261
|
|
|
247
262
|
return api;
|
|
248
263
|
}
|
|
249
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Checks if a response is HTML content
|
|
267
|
+
*/
|
|
268
|
+
function isHTMLContent(response: unknown): response is Response {
|
|
269
|
+
const hasHTMLContentType = response instanceof Response && response.headers.get('Content-Type') === 'text/html';
|
|
270
|
+
const isHTMLTemplate = isHSHtml(response);
|
|
271
|
+
const isHTMLString = typeof response === 'string' && response.trim().startsWith('<');
|
|
272
|
+
|
|
273
|
+
return hasHTMLContentType || isHTMLTemplate || isHTMLString;
|
|
274
|
+
}
|
|
275
|
+
|
|
250
276
|
/**
|
|
251
277
|
* Return HTML response from userland route handler
|
|
252
278
|
*/
|
|
253
279
|
export async function returnHTMLResponse(
|
|
254
|
-
context:
|
|
280
|
+
context: HS.Context,
|
|
255
281
|
handlerFn: () => unknown,
|
|
256
|
-
responseOptions?: { status?:
|
|
282
|
+
responseOptions?: { status?: number; headers?: Record<string, string> }
|
|
257
283
|
): Promise<Response> {
|
|
258
284
|
try {
|
|
259
285
|
const routeContent = await handlerFn();
|
|
@@ -263,13 +289,12 @@ export async function returnHTMLResponse(
|
|
|
263
289
|
return routeContent;
|
|
264
290
|
}
|
|
265
291
|
|
|
266
|
-
// @TODO: Move this to config or something...
|
|
267
|
-
const userIsBot = isbot(context.req.header('User-Agent'));
|
|
268
|
-
const streamOpt = context.req.query('__nostream');
|
|
269
|
-
const streamingEnabled = !userIsBot && (streamOpt !== undefined ? streamOpt : true);
|
|
270
|
-
|
|
271
292
|
// Render HSHtml if returned from route handler
|
|
272
293
|
if (isHSHtml(routeContent)) {
|
|
294
|
+
// @TODO: Move this to config or something...
|
|
295
|
+
const streamOpt = context.req.query.get('__nostream');
|
|
296
|
+
const streamingEnabled = (streamOpt !== undefined ? streamOpt : true);
|
|
297
|
+
|
|
273
298
|
// Stream only if enabled and there is async content to stream
|
|
274
299
|
if (streamingEnabled && (routeContent as HSHtml).asyncContent?.length > 0) {
|
|
275
300
|
return new StreamResponse(
|
|
@@ -278,12 +303,12 @@ export async function returnHTMLResponse(
|
|
|
278
303
|
) as Response;
|
|
279
304
|
} else {
|
|
280
305
|
const output = await renderAsync(routeContent as HSHtml);
|
|
281
|
-
return context.html(output, responseOptions);
|
|
306
|
+
return context.res.html(output, responseOptions);
|
|
282
307
|
}
|
|
283
308
|
}
|
|
284
309
|
|
|
285
310
|
// Return unknown content as string - not specifically handled above
|
|
286
|
-
return context.html(String(routeContent), responseOptions);
|
|
311
|
+
return context.res.html(String(routeContent), responseOptions);
|
|
287
312
|
} catch (e) {
|
|
288
313
|
!IS_PROD && console.error(e);
|
|
289
314
|
return await showErrorReponse(context, e as Error, responseOptions);
|
|
@@ -294,23 +319,23 @@ export async function returnHTMLResponse(
|
|
|
294
319
|
* Get a Hyperspan runnable route from a module import
|
|
295
320
|
* @throws Error if no runnable route found
|
|
296
321
|
*/
|
|
297
|
-
export function getRunnableRoute(route: unknown):
|
|
322
|
+
export function getRunnableRoute(route: unknown, routeConfig?: HS.RouteConfig): HS.Route {
|
|
298
323
|
// Runnable already? Just return it
|
|
299
324
|
if (isRunnableRoute(route)) {
|
|
300
|
-
return route as
|
|
325
|
+
return route as HS.Route;
|
|
301
326
|
}
|
|
302
327
|
|
|
303
328
|
const kind = typeof route;
|
|
304
329
|
|
|
305
330
|
// Plain function - wrap in createRoute()
|
|
306
331
|
if (kind === 'function') {
|
|
307
|
-
return createRoute(route as
|
|
332
|
+
return createRoute(routeConfig).get(route as HS.RouteHandler);
|
|
308
333
|
}
|
|
309
334
|
|
|
310
335
|
// Module - get default and use it
|
|
311
336
|
// @ts-ignore
|
|
312
337
|
if (kind === 'object' && 'default' in route) {
|
|
313
|
-
return getRunnableRoute(route.default);
|
|
338
|
+
return getRunnableRoute(route.default, routeConfig);
|
|
314
339
|
}
|
|
315
340
|
|
|
316
341
|
// No route -> error
|
|
@@ -327,10 +352,18 @@ export function isRunnableRoute(route: unknown): boolean {
|
|
|
327
352
|
return false;
|
|
328
353
|
}
|
|
329
354
|
|
|
330
|
-
const obj = route as { _kind: string;
|
|
331
|
-
|
|
355
|
+
const obj = route as { _kind: string; fetch: (request: Request) => Promise<Response> };
|
|
356
|
+
return 'hsRoute' === obj?._kind && 'fetch' in obj;
|
|
357
|
+
}
|
|
332
358
|
|
|
333
|
-
|
|
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;
|
|
334
367
|
}
|
|
335
368
|
|
|
336
369
|
/**
|
|
@@ -338,22 +371,22 @@ export function isRunnableRoute(route: unknown): boolean {
|
|
|
338
371
|
* @TODO: Should check for and load user-customizeable template with special name (app/__error.ts ?)
|
|
339
372
|
*/
|
|
340
373
|
async function showErrorReponse(
|
|
341
|
-
context:
|
|
374
|
+
context: HS.Context,
|
|
342
375
|
err: Error,
|
|
343
|
-
responseOptions?: { status?:
|
|
376
|
+
responseOptions?: { status?: number; headers?: Record<string, string> }
|
|
344
377
|
) {
|
|
345
|
-
let status:
|
|
378
|
+
let status: number = 500;
|
|
346
379
|
const message = err.message || 'Internal Server Error';
|
|
347
380
|
|
|
348
381
|
// Send correct status code if HTTPException
|
|
349
382
|
if (err instanceof HTTPException) {
|
|
350
|
-
status = err.status
|
|
383
|
+
status = err.status;
|
|
351
384
|
}
|
|
352
385
|
|
|
353
386
|
const stack = !IS_PROD && err.stack ? err.stack.split('\n').slice(1).join('\n') : '';
|
|
354
387
|
|
|
355
388
|
// Partial request (no layout - usually from actions)
|
|
356
|
-
if (context.req.
|
|
389
|
+
if (context.req.headers.get('X-Request-Type') === 'partial') {
|
|
357
390
|
const output = render(html`
|
|
358
391
|
<section style="padding: 20px;">
|
|
359
392
|
<p style="margin-bottom: 10px;"><strong>Error</strong></p>
|
|
@@ -361,7 +394,7 @@ async function showErrorReponse(
|
|
|
361
394
|
${stack ? html`<pre>${stack}</pre>` : ''}
|
|
362
395
|
</section>
|
|
363
396
|
`);
|
|
364
|
-
return context.html(output, Object.assign({ status }, responseOptions));
|
|
397
|
+
return context.res.html(output, Object.assign({ status }, responseOptions));
|
|
365
398
|
}
|
|
366
399
|
|
|
367
400
|
const output = render(html`
|
|
@@ -382,233 +415,28 @@ async function showErrorReponse(
|
|
|
382
415
|
</html>
|
|
383
416
|
`);
|
|
384
417
|
|
|
385
|
-
return context.html(output, Object.assign({ status }, responseOptions));
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
export type THSServerConfig = {
|
|
389
|
-
appDir: string;
|
|
390
|
-
staticFileRoot: string;
|
|
391
|
-
rewrites?: Array<{ source: string; destination: string }>;
|
|
392
|
-
islandPlugins?: Array<any>; // Loaders for client islands
|
|
393
|
-
// For customizing the routes and adding your own...
|
|
394
|
-
beforeRoutesAdded?: (app: Hono) => void;
|
|
395
|
-
afterRoutesAdded?: (app: Hono) => void;
|
|
396
|
-
};
|
|
397
|
-
|
|
398
|
-
export type THSRouteMap = {
|
|
399
|
-
file: string;
|
|
400
|
-
route: string;
|
|
401
|
-
params: string[];
|
|
402
|
-
module?: any;
|
|
403
|
-
};
|
|
404
|
-
|
|
405
|
-
/**
|
|
406
|
-
* Build routes
|
|
407
|
-
*/
|
|
408
|
-
const ROUTE_SEGMENT = /(\[[a-zA-Z_\.]+\])/g;
|
|
409
|
-
export async function buildRoutes(config: THSServerConfig): Promise<THSRouteMap[]> {
|
|
410
|
-
// Walk all pages and add them as routes
|
|
411
|
-
const routesDir = join(config.appDir, 'routes');
|
|
412
|
-
const files = await readdir(routesDir, { recursive: true });
|
|
413
|
-
const routes: THSRouteMap[] = [];
|
|
414
|
-
|
|
415
|
-
for (const file of files) {
|
|
416
|
-
// No directories or test files
|
|
417
|
-
if (
|
|
418
|
-
!file.includes('.') ||
|
|
419
|
-
basename(file).startsWith('.') ||
|
|
420
|
-
file.includes('.test.') ||
|
|
421
|
-
file.includes('.spec.')
|
|
422
|
-
) {
|
|
423
|
-
continue;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
let route = '/' + file.replace(extname(file), '');
|
|
427
|
-
|
|
428
|
-
// Index files
|
|
429
|
-
if (route.endsWith('index')) {
|
|
430
|
-
route = route === 'index' ? '/' : route.substring(0, route.length - 6);
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// Dynamic params
|
|
434
|
-
let params: string[] = [];
|
|
435
|
-
const dynamicPaths = ROUTE_SEGMENT.test(route);
|
|
436
|
-
|
|
437
|
-
if (dynamicPaths) {
|
|
438
|
-
params = [];
|
|
439
|
-
route = route.replace(ROUTE_SEGMENT, (match: string) => {
|
|
440
|
-
const paramName = match.replace(/[^a-zA-Z_\.]+/g, '');
|
|
441
|
-
|
|
442
|
-
if (match.includes('...')) {
|
|
443
|
-
params.push(paramName.replace('...', ''));
|
|
444
|
-
return '*';
|
|
445
|
-
} else {
|
|
446
|
-
params.push(paramName);
|
|
447
|
-
return ':' + paramName;
|
|
448
|
-
}
|
|
449
|
-
});
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
routes.push({
|
|
453
|
-
file: join('./', routesDir, file),
|
|
454
|
-
route: normalizePath(route || '/'),
|
|
455
|
-
params,
|
|
456
|
-
});
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
// Import all routes at once
|
|
460
|
-
return await Promise.all(
|
|
461
|
-
routes.map(async (route) => {
|
|
462
|
-
route.module = (await import(join(CWD, route.file))).default;
|
|
463
|
-
|
|
464
|
-
return route;
|
|
465
|
-
})
|
|
466
|
-
);
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
/**
|
|
470
|
-
* Build Hyperspan Actions
|
|
471
|
-
*/
|
|
472
|
-
export async function buildActions(config: THSServerConfig): Promise<THSRouteMap[]> {
|
|
473
|
-
// Walk all pages and add them as routes
|
|
474
|
-
const routesDir = join(config.appDir, 'actions');
|
|
475
|
-
const files = await readdir(routesDir, { recursive: true });
|
|
476
|
-
const routes: THSRouteMap[] = [];
|
|
477
|
-
|
|
478
|
-
for (const file of files) {
|
|
479
|
-
// No directories or test files
|
|
480
|
-
if (
|
|
481
|
-
!file.includes('.') ||
|
|
482
|
-
basename(file).startsWith('.') ||
|
|
483
|
-
file.includes('.test.') ||
|
|
484
|
-
file.includes('.spec.')
|
|
485
|
-
) {
|
|
486
|
-
continue;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
let route = assetHash('/' + file.replace(extname(file), ''));
|
|
490
|
-
|
|
491
|
-
routes.push({
|
|
492
|
-
file: join('./', routesDir, file),
|
|
493
|
-
route: `/__actions/${route}`,
|
|
494
|
-
params: [],
|
|
495
|
-
});
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// Import all routes at once
|
|
499
|
-
return await Promise.all(
|
|
500
|
-
routes.map(async (route) => {
|
|
501
|
-
route.module = (await import(join(CWD, route.file))).default;
|
|
502
|
-
route.route = route.module._route;
|
|
503
|
-
|
|
504
|
-
return route;
|
|
505
|
-
})
|
|
506
|
-
);
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/**
|
|
510
|
-
* Run route from file
|
|
511
|
-
*/
|
|
512
|
-
export function createRouteFromModule(
|
|
513
|
-
RouteModule: any
|
|
514
|
-
): Array<MiddlewareHandler | ((context: THSContext) => HandlerResponse<any>)> {
|
|
515
|
-
const route = getRunnableRoute(RouteModule);
|
|
516
|
-
return route._getRouteHandlers();
|
|
418
|
+
return context.res.html(output, Object.assign({ status }, responseOptions));
|
|
517
419
|
}
|
|
518
420
|
|
|
519
|
-
/**
|
|
520
|
-
* Create and start Bun HTTP server
|
|
521
|
-
*/
|
|
522
|
-
export async function createServer(config: THSServerConfig): Promise<Hono> {
|
|
523
|
-
// Build client JS and CSS bundles so they are available for templates when streaming starts
|
|
524
|
-
await Promise.all([buildClientJS(), buildClientCSS(), clientJSPlugin(config)]);
|
|
525
|
-
|
|
526
|
-
const app = new Hono();
|
|
527
|
-
|
|
528
|
-
app.use(csrf());
|
|
529
|
-
|
|
530
|
-
// [Customization] Before routes added...
|
|
531
|
-
config.beforeRoutesAdded && config.beforeRoutesAdded(app);
|
|
532
|
-
|
|
533
|
-
const [routes, actions] = await Promise.all([buildRoutes(config), buildActions(config)]);
|
|
534
|
-
|
|
535
|
-
// Scan routes folder and add all file routes to the router
|
|
536
|
-
const fileRoutes = routes.concat(actions);
|
|
537
|
-
const routeMap = [];
|
|
538
|
-
|
|
539
|
-
for (let i = 0; i < fileRoutes.length; i++) {
|
|
540
|
-
let route = fileRoutes[i];
|
|
541
|
-
|
|
542
|
-
routeMap.push({ route: route.route, file: route.file });
|
|
543
|
-
|
|
544
|
-
// Ensure route module was imported and exists (it should...)
|
|
545
|
-
if (!route.module) {
|
|
546
|
-
throw new Error(`Route module not loaded! File: ${route.file}`);
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
const routeHandlers = createRouteFromModule(route.module);
|
|
550
|
-
app.all(route.route, ...routeHandlers);
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
// Help route if no routes found
|
|
554
|
-
if (routeMap.length === 0) {
|
|
555
|
-
app.get('/', (context) => {
|
|
556
|
-
return context.text(
|
|
557
|
-
'No routes found. Add routes to app/routes. Example: `app/routes/index.ts`',
|
|
558
|
-
{ status: 404 }
|
|
559
|
-
);
|
|
560
|
-
});
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
// Display routing table for dev env
|
|
564
|
-
if (!IS_PROD) {
|
|
565
|
-
console.log('[Hyperspan] File system routes (in app/routes):');
|
|
566
|
-
console.table(routeMap);
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
// [Customization] After routes added...
|
|
570
|
-
config.afterRoutesAdded && config.afterRoutesAdded(app);
|
|
571
|
-
|
|
572
|
-
// Static files and catchall
|
|
573
|
-
app.use(
|
|
574
|
-
'*',
|
|
575
|
-
serveStatic({
|
|
576
|
-
root: config.staticFileRoot,
|
|
577
|
-
onFound: IS_PROD
|
|
578
|
-
? (_, c) => {
|
|
579
|
-
// Cache static assets in prod (default 30 days)
|
|
580
|
-
c.header('Cache-Control', 'public, max-age=2592000');
|
|
581
|
-
}
|
|
582
|
-
: undefined,
|
|
583
|
-
})
|
|
584
|
-
);
|
|
585
|
-
|
|
586
|
-
app.notFound((context) => {
|
|
587
|
-
// @TODO: Add a custom 404 route
|
|
588
|
-
return context.text('Not... found?', { status: 404 });
|
|
589
|
-
});
|
|
590
|
-
|
|
591
|
-
return app;
|
|
592
|
-
}
|
|
593
421
|
|
|
594
422
|
/**
|
|
595
423
|
* Streaming HTML Response
|
|
596
424
|
*/
|
|
597
425
|
export class StreamResponse extends Response {
|
|
598
|
-
constructor(iterator: AsyncIterator<unknown>, options = {}) {
|
|
426
|
+
constructor(iterator: AsyncIterator<unknown>, options: { status?: number; headers?: Record<string, string> } = {}) {
|
|
599
427
|
super();
|
|
428
|
+
const { status, headers, ...restOptions } = options;
|
|
600
429
|
const stream = createReadableStreamFromAsyncGenerator(iterator as AsyncGenerator);
|
|
601
430
|
|
|
602
431
|
return new Response(stream, {
|
|
603
|
-
status: 200,
|
|
432
|
+
status: status ?? 200,
|
|
604
433
|
headers: {
|
|
605
434
|
'Transfer-Encoding': 'chunked',
|
|
606
435
|
'Content-Type': 'text/html; charset=UTF-8',
|
|
607
436
|
'Content-Encoding': 'Identity',
|
|
608
|
-
|
|
609
|
-
...(options?.headers ?? {}),
|
|
437
|
+
...(headers ?? {}),
|
|
610
438
|
},
|
|
611
|
-
...
|
|
439
|
+
...restOptions,
|
|
612
440
|
});
|
|
613
441
|
}
|
|
614
442
|
}
|
|
@@ -638,9 +466,48 @@ export function createReadableStreamFromAsyncGenerator(output: AsyncGenerator) {
|
|
|
638
466
|
* Normalize URL path
|
|
639
467
|
* Removes trailing slash and lowercases path
|
|
640
468
|
*/
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
+
};
|
|
646
513
|
}
|