@hyperspan/framework 0.1.3 → 0.1.5

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
@@ -3,256 +3,210 @@ import { basename, extname, join } from 'node:path';
3
3
  import { TmplHtml, html, renderStream, renderAsync, render } from '@hyperspan/html';
4
4
  import { isbot } from 'isbot';
5
5
  import { buildClientJS, buildClientCSS } from './assets';
6
- import { Hono } from 'hono';
6
+ import { Hono, type Context } from 'hono';
7
7
  import { serveStatic } from 'hono/bun';
8
- import * as z from 'zod';
9
- import type { Context, Handler } from 'hono';
8
+ import { HTTPException } from 'hono/http-exception';
10
9
 
11
10
  export const IS_PROD = process.env.NODE_ENV === 'production';
12
11
  const CWD = process.cwd();
13
12
 
14
- /**
15
- * Route
16
- * Define a route that can handle a direct HTTP request
17
- * Route handlers should return a Response or TmplHtml object
18
- */
19
- export function createRoute(handler: Handler): HSRoute {
20
- return new HSRoute(handler);
21
- }
22
-
23
- /**
24
- * Component
25
- * Define a component or partial with an optional loading placeholder
26
- * These can be rendered anywhere inside other templates - even if async.
27
- */
28
- export function createComponent(render: () => THSComponentReturn | Promise<THSComponentReturn>) {
29
- return new HSComponent(render);
30
- }
31
-
32
- /**
33
- * Form + route handler
34
- * Automatically handles and parses form data
35
- *
36
- * INITIAL IDEA OF HOW THIS WILL WORK:
37
- * ---
38
- * 1. Renders component as initial form markup for GET request
39
- * 2. Bind form onSubmit function to custom client JS handling
40
- * 3. Submits form with JavaScript fetch()
41
- * 4. Replaces form content with content from server
42
- * 5. All validation and save logic is on the server
43
- * 6. Handles any Exception thrown on server as error displayed in client
44
- */
45
- export function createForm(
46
- renderForm: (data?: any) => THSResponseTypes,
47
- schema?: z.ZodSchema | null
48
- ): HSFormRoute {
49
- return new HSFormRoute(renderForm, schema);
50
- }
51
-
52
13
  /**
53
14
  * Types
54
15
  */
55
- export type THSComponentReturn = TmplHtml | string | number | null;
56
16
  export type THSResponseTypes = TmplHtml | Response | string | null;
57
- export const HS_DEFAULT_LOADING = () => html`<div>Loading...</div>`;
58
-
59
- /**
60
- * Route handler helper
61
- */
62
- export class HSComponent {
63
- _kind = 'hsComponent';
64
- _handlers: Record<string, Handler> = {};
65
- _loading?: () => TmplHtml;
66
- render: () => THSComponentReturn | Promise<THSComponentReturn>;
67
- constructor(render: () => THSComponentReturn | Promise<THSComponentReturn>) {
68
- this.render = render;
69
- }
70
-
71
- loading(fn: () => TmplHtml) {
72
- this._loading = fn;
73
- return this;
74
- }
75
- }
76
-
77
- /**
78
- * Route handler helper
79
- */
80
- export class HSRoute {
81
- _kind = 'hsRoute';
82
- _handlers: Record<string, Handler> = {};
83
- _methods: null | string[] = null;
84
- constructor(handler: Handler) {
85
- this._handlers.GET = handler;
86
- }
87
- }
17
+ export type THSRouteHandler = (context: Context) => THSResponseTypes | Promise<THSResponseTypes>;
18
+
19
+ export type THSRoute = {
20
+ _kind: 'hsRoute';
21
+ get: (handler: THSRouteHandler) => THSRoute;
22
+ post: (handler: THSRouteHandler) => THSRoute;
23
+ put: (handler: THSRouteHandler) => THSRoute;
24
+ delete: (handler: THSRouteHandler) => THSRoute;
25
+ patch: (handler: THSRouteHandler) => THSRoute;
26
+ run: (method: string, context: Context) => Promise<Response>;
27
+ };
88
28
 
89
29
  /**
90
- * Form route handler helper
30
+ * Define a route that can handle a direct HTTP request.
31
+ * Route handlers should return a TmplHtml or Response object
91
32
  */
92
- export type THSFormRenderer = (data?: any) => THSResponseTypes;
93
- export class HSFormRoute {
94
- _kind = 'hsFormRoute';
95
- _handlers: Record<string, Handler> = {};
96
- _form: THSFormRenderer;
97
- _methods: null | string[] = null;
98
- _schema: null | z.ZodSchema = null;
99
-
100
- constructor(renderForm: THSFormRenderer, schema: z.ZodSchema | null = null) {
101
- // Haz schema?
102
- if (schema) {
103
- type TSchema = z.infer<typeof schema>;
104
- this._form = renderForm as (data: TSchema) => THSResponseTypes;
105
- this._schema = schema;
106
- } else {
107
- this._form = renderForm;
108
- }
33
+ export function createRoute(handler?: THSRouteHandler): THSRoute {
34
+ let _handlers: Record<string, THSRouteHandler> = {};
109
35
 
110
- // GET request is render form by default
111
- this._handlers.GET = () => renderForm(this.getDefaultData());
36
+ if (handler) {
37
+ _handlers['GET'] = handler;
112
38
  }
113
39
 
114
- // Form data
115
- getDefaultData() {
116
- if (!this._schema) {
117
- return {};
118
- }
119
-
120
- type TSchema = z.infer<typeof this._schema>;
121
- const data = z.parse(this._schema, {});
122
- return data as TSchema;
123
- }
124
-
125
- /**
126
- * Get form renderer method
127
- */
128
- renderForm(data?: any) {
129
- return this._form(data || this.getDefaultData());
130
- }
40
+ const api: THSRoute = {
41
+ _kind: 'hsRoute',
42
+ get(handler: THSRouteHandler) {
43
+ _handlers['GET'] = handler;
44
+ return api;
45
+ },
46
+ post(handler: THSRouteHandler) {
47
+ _handlers['POST'] = handler;
48
+ return api;
49
+ },
50
+ put(handler: THSRouteHandler) {
51
+ _handlers['PUT'] = handler;
52
+ return api;
53
+ },
54
+ delete(handler: THSRouteHandler) {
55
+ _handlers['DELETE'] = handler;
56
+ return api;
57
+ },
58
+ patch(handler: THSRouteHandler) {
59
+ _handlers['PATCH'] = handler;
60
+ return api;
61
+ },
62
+ async run(method: string, context: Context): Promise<Response> {
63
+ const handler = _handlers[method];
64
+ if (!handler) {
65
+ throw new HTTPException(405, { message: 'Method not allowed' });
66
+ }
131
67
 
132
- // HTTP handlers
133
- get(handler: Handler) {
134
- this._handlers.GET = handler;
135
- return this;
136
- }
68
+ const routeContent = await handler(context);
137
69
 
138
- patch(handler: Handler) {
139
- this._handlers.PATCH = handler;
140
- return this;
141
- }
70
+ // Return Response if returned from route handler
71
+ if (routeContent instanceof Response) {
72
+ return routeContent;
73
+ }
142
74
 
143
- post(handler: Handler) {
144
- this._handlers.POST = handler;
145
- return this;
146
- }
75
+ // @TODO: Move this to config or something...
76
+ const userIsBot = isbot(context.req.header('User-Agent'));
77
+ const streamOpt = context.req.query('__nostream');
78
+ const streamingEnabled = !userIsBot && (streamOpt !== undefined ? streamOpt : true);
79
+ const routeKind = typeof routeContent;
80
+
81
+ // Render TmplHtml if returned from route handler
82
+ if (
83
+ routeContent &&
84
+ routeKind === 'object' &&
85
+ (routeContent instanceof TmplHtml ||
86
+ routeContent.constructor.name === 'TmplHtml' ||
87
+ // @ts-ignore
88
+ routeContent?._kind === 'TmplHtml')
89
+ ) {
90
+ if (streamingEnabled) {
91
+ return new StreamResponse(renderStream(routeContent as TmplHtml)) as Response;
92
+ } else {
93
+ const output = await renderAsync(routeContent as TmplHtml);
94
+ return context.html(output);
95
+ }
96
+ }
147
97
 
148
- put(handler: Handler) {
149
- this._handlers.PUT = handler;
150
- return this;
151
- }
98
+ // Return unknown content - not specifically handled above
99
+ return context.text(String(routeContent));
100
+ },
101
+ };
152
102
 
153
- delete(handler: Handler) {
154
- this._handlers.DELETE = handler;
155
- return this;
156
- }
103
+ return api;
157
104
  }
158
105
 
159
106
  /**
160
- * Run route from file
107
+ * Create new API Route
108
+ * API Route handlers should return a JSON object or a Response
161
109
  */
162
- export async function runFileRoute(RouteModule: any, context: Context): Promise<Response | false> {
163
- const req = context.req;
164
- const url = new URL(req.url);
165
- const qs = url.searchParams;
166
-
167
- // @TODO: Move this to config or something...
168
- const userIsBot = isbot(context.req.header('User-Agent'));
169
- const streamOpt = qs.get('__nostream') ? !Boolean(qs.get('__nostream')) : undefined;
170
- const streamingEnabled = !userIsBot && (streamOpt !== undefined ? streamOpt : true);
171
-
172
- // Route module
173
- const RouteComponent = RouteModule.default;
174
- const reqMethod = req.method.toUpperCase();
175
-
176
- try {
177
- // API Route?
178
- if (RouteModule[reqMethod] !== undefined) {
179
- return await runAPIRoute(RouteModule[reqMethod], context);
180
- }
181
-
182
- let routeContent;
110
+ export function createAPIRoute(handler?: THSRouteHandler): THSRoute {
111
+ let _handlers: Record<string, THSRouteHandler> = {};
183
112
 
184
- // No default export in this file...
185
- if (!RouteComponent) {
186
- throw new Error('No route was exported by default in matched route file.');
187
- }
188
-
189
- // Route component
190
- if (typeof RouteComponent._handlers !== 'undefined') {
191
- const routeMethodHandler = RouteComponent._handlers[reqMethod];
113
+ if (handler) {
114
+ _handlers['GET'] = handler;
115
+ }
192
116
 
193
- if (!routeMethodHandler) {
194
- return new Response('Method Not Allowed', {
195
- status: 405,
196
- headers: { 'content-type': 'text/plain' },
197
- });
117
+ const api: THSRoute = {
118
+ _kind: 'hsRoute',
119
+ get(handler: THSRouteHandler) {
120
+ _handlers['GET'] = handler;
121
+ return api;
122
+ },
123
+ post(handler: THSRouteHandler) {
124
+ _handlers['POST'] = handler;
125
+ return api;
126
+ },
127
+ put(handler: THSRouteHandler) {
128
+ _handlers['PUT'] = handler;
129
+ return api;
130
+ },
131
+ delete(handler: THSRouteHandler) {
132
+ _handlers['DELETE'] = handler;
133
+ return api;
134
+ },
135
+ patch(handler: THSRouteHandler) {
136
+ _handlers['PATCH'] = handler;
137
+ return api;
138
+ },
139
+ async run(method: string, context: Context): Promise<Response> {
140
+ const handler = _handlers[method];
141
+ if (!handler) {
142
+ throw new Error('Method not allowed');
198
143
  }
199
144
 
200
- routeContent = await routeMethodHandler(context);
201
- } else {
202
- routeContent = await RouteComponent(context);
203
- }
145
+ try {
146
+ const response = await handler(context);
204
147
 
205
- if (routeContent instanceof Response) {
206
- return routeContent;
207
- }
148
+ if (response instanceof Response) {
149
+ return response;
150
+ }
208
151
 
209
- let routeKind = typeof routeContent;
210
-
211
- // Render TmplHtml if returned from route handler
212
- if (
213
- routeKind === 'object' &&
214
- (routeContent instanceof TmplHtml ||
215
- routeContent.constructor.name === 'TmplHtml' ||
216
- routeContent?._kind === 'TmplHtml')
217
- ) {
218
- if (streamingEnabled) {
219
- return new StreamResponse(renderStream(routeContent)) as Response;
220
- } else {
221
- const output = await renderAsync(routeContent);
222
- return context.html(output);
152
+ return context.json(
153
+ { meta: { success: true, dtResponse: new Date() }, data: response },
154
+ { status: 200 }
155
+ );
156
+ } catch (err) {
157
+ const e = err as Error;
158
+ console.error(e);
159
+
160
+ return context.json(
161
+ {
162
+ meta: { success: false, dtResponse: new Date() },
163
+ data: {},
164
+ error: {
165
+ message: e.message,
166
+ stack: IS_PROD ? undefined : e.stack?.split('\n'),
167
+ },
168
+ },
169
+ { status: 500 }
170
+ );
223
171
  }
224
- }
225
-
226
- console.log('Returning unknown type... ', routeContent);
172
+ },
173
+ };
227
174
 
228
- return routeContent;
229
- } catch (e) {
230
- console.error(e);
231
- return await showErrorReponse(context, e as Error);
232
- }
175
+ return api;
233
176
  }
234
177
 
235
178
  /**
236
- * Run route and handle response
179
+ * Get a Hyperspan runnable route from a module import
180
+ * @throws Error if no runnable route found
237
181
  */
238
- async function runAPIRoute(routeFn: any, context: Context, middlewareResult?: any) {
239
- try {
240
- return await routeFn(context, middlewareResult);
241
- } catch (err) {
242
- const e = err as Error;
243
- console.error(e);
244
-
245
- return context.json(
246
- {
247
- meta: { success: false },
248
- data: {
249
- message: e.message,
250
- stack: IS_PROD ? undefined : e.stack?.split('\n'),
251
- },
252
- },
253
- { status: 500 }
254
- );
182
+ export function getRunnableRoute(route: unknown): THSRoute {
183
+ // Runnable already? Just return it
184
+ if (isRunnableRoute(route)) {
185
+ return route as THSRoute;
186
+ }
187
+
188
+ const kind = typeof route;
189
+
190
+ // Plain function - wrap in createRoute()
191
+ if (kind === 'function') {
192
+ return createRoute(route as THSRouteHandler);
193
+ }
194
+
195
+ // Module - get default and use it
196
+ // @ts-ignore
197
+ if (kind === 'object' && 'default' in route) {
198
+ return getRunnableRoute(route.default);
255
199
  }
200
+
201
+ // No route -> error
202
+ throw new Error(
203
+ 'Route not runnable. Use "export default createRoute()" to create a Hyperspan route.'
204
+ );
205
+ }
206
+
207
+ export function isRunnableRoute(route: unknown): boolean {
208
+ // @ts-ignore
209
+ return typeof route === 'object' && 'run' in route;
256
210
  }
257
211
 
258
212
  /**
@@ -341,6 +295,29 @@ export async function buildRoutes(config: THSServerConfig): Promise<THSRouteMap[
341
295
  return routes;
342
296
  }
343
297
 
298
+ /**
299
+ * Run route from file
300
+ */
301
+ export function createRouteFromModule(RouteModule: any): (context: Context) => Promise<Response> {
302
+ return async (context: Context) => {
303
+ const reqMethod = context.req.method.toUpperCase();
304
+
305
+ try {
306
+ const runnableRoute = getRunnableRoute(RouteModule);
307
+ const content = await runnableRoute.run(reqMethod, context);
308
+
309
+ if (content instanceof Response) {
310
+ return content;
311
+ }
312
+
313
+ return context.text(String(content));
314
+ } catch (e) {
315
+ console.error(e);
316
+ return await showErrorReponse(context, e as Error);
317
+ }
318
+ };
319
+ }
320
+
344
321
  /**
345
322
  * Create and start Bun HTTP server
346
323
  */
@@ -365,16 +342,7 @@ export async function createServer(config: THSServerConfig): Promise<Hono> {
365
342
  routeMap.push({ route: routePattern, file: route.file });
366
343
 
367
344
  // Import route
368
- const routeModule = await import(fullRouteFile);
369
-
370
- app.all(routePattern, async (context) => {
371
- const matchedRoute = await runFileRoute(routeModule, context);
372
- if (matchedRoute) {
373
- return matchedRoute as Response;
374
- }
375
-
376
- return context.notFound();
377
- });
345
+ app.all(routePattern, createRouteFromModule(await import(fullRouteFile)));
378
346
  }
379
347
 
380
348
  // Help route if no routes found
@@ -401,6 +369,12 @@ export async function createServer(config: THSServerConfig): Promise<Hono> {
401
369
  '*',
402
370
  serveStatic({
403
371
  root: config.staticFileRoot,
372
+ onFound: IS_PROD
373
+ ? (_, c) => {
374
+ // Cache static assets in prod (default 30 days)
375
+ c.header('Cache-Control', 'public, max-age=2592000');
376
+ }
377
+ : undefined,
404
378
  })
405
379
  );
406
380
 
@@ -422,8 +396,9 @@ export class StreamResponse extends Response {
422
396
  return new Response(stream, {
423
397
  status: 200,
424
398
  headers: {
425
- 'Content-Type': 'text/html',
426
399
  'Transfer-Encoding': 'chunked',
400
+ 'Content-Type': 'text/html; charset=UTF-8',
401
+ 'Content-Encoding': 'Identity',
427
402
  // @ts-ignore
428
403
  ...(options?.headers ?? {}),
429
404
  },