@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/dist/server.d.ts +25 -69
- package/dist/server.js +168 -324
- package/package.json +9 -5
- package/src/actions.test.ts +95 -0
- package/src/actions.ts +189 -0
- package/src/clientjs/hyperspan-client.ts +65 -4
- package/src/server.ts +199 -224
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
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
*
|
|
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
|
|
93
|
-
|
|
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
|
-
|
|
111
|
-
|
|
36
|
+
if (handler) {
|
|
37
|
+
_handlers['GET'] = handler;
|
|
112
38
|
}
|
|
113
39
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
133
|
-
get(handler: Handler) {
|
|
134
|
-
this._handlers.GET = handler;
|
|
135
|
-
return this;
|
|
136
|
-
}
|
|
68
|
+
const routeContent = await handler(context);
|
|
137
69
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
70
|
+
// Return Response if returned from route handler
|
|
71
|
+
if (routeContent instanceof Response) {
|
|
72
|
+
return routeContent;
|
|
73
|
+
}
|
|
142
74
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
98
|
+
// Return unknown content - not specifically handled above
|
|
99
|
+
return context.text(String(routeContent));
|
|
100
|
+
},
|
|
101
|
+
};
|
|
152
102
|
|
|
153
|
-
|
|
154
|
-
this._handlers.DELETE = handler;
|
|
155
|
-
return this;
|
|
156
|
-
}
|
|
103
|
+
return api;
|
|
157
104
|
}
|
|
158
105
|
|
|
159
106
|
/**
|
|
160
|
-
*
|
|
107
|
+
* Create new API Route
|
|
108
|
+
* API Route handlers should return a JSON object or a Response
|
|
161
109
|
*/
|
|
162
|
-
export
|
|
163
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
routeContent = await RouteComponent(context);
|
|
203
|
-
}
|
|
145
|
+
try {
|
|
146
|
+
const response = await handler(context);
|
|
204
147
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
148
|
+
if (response instanceof Response) {
|
|
149
|
+
return response;
|
|
150
|
+
}
|
|
208
151
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
179
|
+
* Get a Hyperspan runnable route from a module import
|
|
180
|
+
* @throws Error if no runnable route found
|
|
237
181
|
*/
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
},
|