@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/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 { readdir } from 'node:fs/promises';
5
- import { basename, extname, join } from 'node:path';
6
- import { isbot } from 'isbot';
7
- import { Hono, type Context } from 'hono';
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
- * Types
17
+ * Ensures a valid config object is returned, even with an empty object or partial object passed in
20
18
  */
21
- export type THSContext = Context<any, any, {}>;
22
- export type THSResponseTypes = HSHtml | Response | string | null;
23
- export type THSRouteHandler = (context: THSContext) => THSResponseTypes | Promise<THSResponseTypes>;
24
- export type THSAPIRouteHandler = (context: THSContext) => Promise<any> | any;
25
-
26
- export type THSRoute = {
27
- _kind: 'hsRoute';
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(handler?: THSRouteHandler): THSRoute {
57
- let _handlers: Record<string, THSRouteHandler> = {};
58
- let _middleware: Array<MiddlewareHandler> = [];
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
- if (handler) {
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: THSRouteHandler) {
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: THSRouteHandler) {
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<MiddlewareHandler>) {
84
- _middleware = middleware;
138
+ middleware(middleware: Array<HS.MiddlewareFunction>) {
139
+ _middleware['*'] = middleware;
85
140
  return api;
86
141
  },
87
- _getRouteHandlers() {
88
- return [
89
- ..._middleware,
90
- async (context: THSContext) => {
91
- const method = context.req.method.toUpperCase();
92
-
93
- // Handle CORS preflight requests
94
- if (method === 'OPTIONS') {
95
- return context.html(
96
- render(html`
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
- status: 200,
102
- headers: {
103
- 'Access-Control-Allow-Origin': '*',
104
- 'Access-Control-Allow-Methods': [
105
- 'HEAD',
106
- 'OPTIONS',
107
- ...Object.keys(_handlers),
108
- ].join(', '),
109
- 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
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
- return handler(context);
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
- * Create new API Route
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 createAPIRoute(handler?: THSAPIRouteHandler): THSAPIRoute {
138
- let _handlers: Record<string, THSAPIRouteHandler> = {};
139
- let _middleware: Array<MiddlewareHandler> = [];
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 (handler) {
142
- _handlers['GET'] = handler;
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: THSAPIRoute = {
146
- _kind: 'hsAPIRoute',
147
- get(handler: THSAPIRouteHandler) {
148
- _handlers['GET'] = handler;
149
- return api;
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
- post(handler: THSAPIRouteHandler) {
152
- _handlers['POST'] = handler;
153
- return api;
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
- put(handler: THSAPIRouteHandler) {
156
- _handlers['PUT'] = handler;
157
- return api;
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
- delete(handler: THSAPIRouteHandler) {
160
- _handlers['DELETE'] = handler;
161
- return api;
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
- patch(handler: THSAPIRouteHandler) {
164
- _handlers['PATCH'] = handler;
165
- return api;
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
- middleware(middleware: Array<MiddlewareHandler>) {
168
- _middleware = middleware;
169
- return api;
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
- _getRouteHandlers() {
172
- return [
173
- ..._middleware,
174
- async (context: THSContext) => {
175
- const method = context.req.method.toUpperCase();
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: THSContext,
280
+ context: HS.Context,
255
281
  handlerFn: () => unknown,
256
- responseOptions?: { status?: ContentfulStatusCode; headers?: Headers | Record<string, string> }
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): THSRoute {
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 THSRoute;
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 THSRouteHandler);
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; _getRouteHandlers: any };
331
- const runnableKind = ['hsRoute', 'hsAPIRoute', 'hsAction'].includes(obj?._kind);
355
+ const obj = route as { _kind: string; fetch: (request: Request) => Promise<Response> };
356
+ return 'hsRoute' === obj?._kind && 'fetch' in obj;
357
+ }
332
358
 
333
- return runnableKind && '_getRouteHandlers' in obj;
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: THSContext,
374
+ context: HS.Context,
342
375
  err: Error,
343
- responseOptions?: { status?: ContentfulStatusCode; headers?: Headers | Record<string, string> }
376
+ responseOptions?: { status?: number; headers?: Record<string, string> }
344
377
  ) {
345
- let status: ContentfulStatusCode = 500;
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 as ContentfulStatusCode;
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.header('X-Request-Type') === 'partial') {
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
- // @ts-ignore
609
- ...(options?.headers ?? {}),
437
+ ...(headers ?? {}),
610
438
  },
611
- ...options,
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
- export function normalizePath(urlPath: string): string {
642
- return (
643
- (urlPath.endsWith('/') ? urlPath.substring(0, urlPath.length - 1) : urlPath).toLowerCase() ||
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
  }