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