@hyperspan/framework 0.0.3 → 0.1.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.
@@ -0,0 +1 @@
1
+ export * from '@preact/compat';
package/src/index.ts CHANGED
@@ -1,14 +1 @@
1
- /**
2
- * Hyperspan
3
- * Only export html/client libs by default just incase this gets in a JS bundle
4
- * Server package must be imported with 'hypserspan/server'
5
- */
6
- export {
7
- _typeOf,
8
- clientComponent,
9
- compressHTMLString,
10
- html,
11
- HSTemplate,
12
- renderToStream,
13
- renderToString,
14
- } from './html';
1
+ export * from './server';
package/src/server.ts CHANGED
@@ -1,91 +1,222 @@
1
- import { readdir } from 'node:fs/promises';
2
- import { basename, extname, join, resolve } from 'node:path';
3
- import { html, renderToStream, renderToString } from './html';
4
- import { isbot } from 'isbot';
5
- import { HSTemplate } from './html';
6
- import { HSApp, HSRequestContext, normalizePath } from './app';
1
+ import {readdir} from 'node:fs/promises';
2
+ import {basename, extname, join} from 'node:path';
3
+ import {TmplHtml, html, renderStream, renderAsync, render} from '@hyperspan/html';
4
+ import {isbot} from 'isbot';
5
+ import {buildClientJS, buildClientCSS} from './assets';
6
+ import {Hono} from 'hono';
7
+ import {serveStatic} from 'hono/bun';
8
+ import * as z from 'zod';
9
+ import type {Context, Handler} from 'hono';
7
10
 
8
11
  export const IS_PROD = process.env.NODE_ENV === 'production';
9
- const PWD = import.meta.dir;
10
12
  const CWD = process.cwd();
11
- const STATIC_FILE_MATCHER = /[^/\\&\?]+\.([a-zA-Z]+)$/;
12
13
 
13
- // Cached route components
14
- const _routeCache: { [key: string]: any } = {};
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
+ /**
53
+ * Types
54
+ */
55
+ export type THSComponentReturn = TmplHtml | string | number | null;
56
+ export type THSResponseTypes = TmplHtml | Response | string | null;
57
+ export const HS_DEFAULT_LOADING = () => html`<div>Loading...</div>`;
15
58
 
16
59
  /**
17
- * Did request come from a bot?
60
+ * Route handler helper
18
61
  */
19
- function requestIsBot(req: Request) {
20
- const ua = req.headers.get('User-Agent');
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
+ }
21
76
 
22
- return ua ? isbot(ua) : false;
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
+ }
88
+
89
+ /**
90
+ * Form route handler helper
91
+ */
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
+ }
109
+
110
+ // GET request is render form by default
111
+ this._handlers.GET = () => renderForm(this.getDefaultData());
112
+ }
113
+
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
+ }
131
+
132
+ // HTTP handlers
133
+ get(handler: Handler) {
134
+ this._handlers.GET = handler;
135
+ return this;
136
+ }
137
+
138
+ patch(handler: Handler) {
139
+ this._handlers.PATCH = handler;
140
+ return this;
141
+ }
142
+
143
+ post(handler: Handler) {
144
+ this._handlers.POST = handler;
145
+ return this;
146
+ }
147
+
148
+ put(handler: Handler) {
149
+ this._handlers.PUT = handler;
150
+ return this;
151
+ }
152
+
153
+ delete(handler: Handler) {
154
+ this._handlers.DELETE = handler;
155
+ return this;
156
+ }
23
157
  }
24
158
 
25
159
  /**
26
160
  * Run route from file
27
161
  */
28
- export async function runFileRoute(routeFile: string, context: HSRequestContext) {
162
+ export async function runFileRoute(RouteModule: any, context: Context): Promise<Response | false> {
29
163
  const req = context.req;
30
164
  const url = new URL(req.url);
31
165
  const qs = url.searchParams;
32
166
 
33
167
  // @TODO: Move this to config or something...
168
+ const userIsBot = isbot(context.req.header('User-Agent'));
34
169
  const streamOpt = qs.get('__nostream') ? !Boolean(qs.get('__nostream')) : undefined;
35
- const streamingEnabled = streamOpt !== undefined ? streamOpt : true;
36
-
37
- // Import route component
38
- const RouteModule = _routeCache[routeFile] || (await import(routeFile));
39
-
40
- if (IS_PROD) {
41
- // Only cache routes in prod
42
- _routeCache[routeFile] = RouteModule;
43
- }
170
+ const streamingEnabled = !userIsBot && (streamOpt !== undefined ? streamOpt : true);
44
171
 
45
172
  // Route module
46
173
  const RouteComponent = RouteModule.default;
47
174
  const reqMethod = req.method.toUpperCase();
48
175
 
49
- // Middleware?
50
- const routeMiddleware = RouteModule.middleware || {}; // Example: { auth: apiAuth, logger: logMiddleware, }
51
- const middlewareResult: any = {};
52
-
53
176
  try {
54
- // Run middleware if present...
55
- if (Object.keys(routeMiddleware).length) {
56
- for (const mKey in routeMiddleware) {
57
- const mRes = await routeMiddleware[mKey](context);
58
-
59
- if (mRes instanceof Response) {
60
- return context.resMerge(mRes);
61
- }
62
-
63
- middlewareResult[mKey] = mRes;
64
- }
65
- }
66
-
67
177
  // API Route?
68
178
  if (RouteModule[reqMethod] !== undefined) {
69
- return await runAPIRoute(RouteModule[reqMethod], context, middlewareResult);
179
+ return await runAPIRoute(RouteModule[reqMethod], context);
70
180
  }
71
181
 
72
- // Route component
73
- const routeContent = await RouteComponent(context, middlewareResult);
182
+ let routeContent;
74
183
 
75
- if (routeContent instanceof Response) {
76
- return context.resMerge(routeContent);
184
+ // No default export in this file...
185
+ if (!RouteComponent) {
186
+ throw new Error('No route was exported by default in matched route file.');
77
187
  }
78
188
 
79
- if (streamingEnabled && !requestIsBot(req)) {
80
- return context.resMerge(new StreamResponse(renderToStream(routeContent)) as Response);
189
+ // Route component
190
+ if (typeof RouteComponent._handlers !== 'undefined') {
191
+ const routeMethodHandler = RouteComponent._handlers[reqMethod];
192
+
193
+ if (!routeMethodHandler) {
194
+ return new Response('Method Not Allowed', {
195
+ status: 405,
196
+ headers: {'content-type': 'text/plain'},
197
+ });
198
+ }
199
+
200
+ routeContent = await routeMethodHandler(context);
81
201
  } else {
82
- // Render content and template
83
- // TODO: Use any context variables from RouteComponent rendering to set values in layout (dynamic title, etc.)...
84
- const output = await renderToString(routeContent);
202
+ routeContent = await RouteComponent(context);
203
+ }
85
204
 
86
- // Render it...
87
- return context.html(output);
205
+ if (routeContent instanceof Response) {
206
+ return routeContent;
88
207
  }
208
+
209
+ // Render TmplHtml if returned from route handler
210
+ if (routeContent instanceof TmplHtml) {
211
+ if (streamingEnabled) {
212
+ return new StreamResponse(renderStream(routeContent)) as Response;
213
+ } else {
214
+ const output = await renderAsync(routeContent);
215
+ return context.html(output);
216
+ }
217
+ }
218
+
219
+ return routeContent;
89
220
  } catch (e) {
90
221
  console.error(e);
91
222
  return await showErrorReponse(context, e as Error);
@@ -95,7 +226,7 @@ export async function runFileRoute(routeFile: string, context: HSRequestContext)
95
226
  /**
96
227
  * Run route and handle response
97
228
  */
98
- async function runAPIRoute(routeFn: any, context: HSRequestContext, middlewareResult?: any) {
229
+ async function runAPIRoute(routeFn: any, context: Context, middlewareResult?: any) {
99
230
  try {
100
231
  return await routeFn(context, middlewareResult);
101
232
  } catch (err) {
@@ -104,13 +235,13 @@ async function runAPIRoute(routeFn: any, context: HSRequestContext, middlewareRe
104
235
 
105
236
  return context.json(
106
237
  {
107
- meta: { success: false },
238
+ meta: {success: false},
108
239
  data: {
109
240
  message: e.message,
110
241
  stack: IS_PROD ? undefined : e.stack?.split('\n'),
111
242
  },
112
243
  },
113
- { status: 500 }
244
+ {status: 500}
114
245
  );
115
246
  }
116
247
  }
@@ -119,8 +250,8 @@ async function runAPIRoute(routeFn: any, context: HSRequestContext, middlewareRe
119
250
  * Basic error handling
120
251
  * @TODO: Should check for and load user-customizeable template with special name (app/__error.ts ?)
121
252
  */
122
- async function showErrorReponse(context: HSRequestContext, err: Error) {
123
- const output = await renderToString(html`
253
+ async function showErrorReponse(context: Context, err: Error) {
254
+ const output = render(html`
124
255
  <main>
125
256
  <h1>Error</h1>
126
257
  <pre>${err.message}</pre>
@@ -136,9 +267,10 @@ async function showErrorReponse(context: HSRequestContext, err: Error) {
136
267
  export type THSServerConfig = {
137
268
  appDir: string;
138
269
  staticFileRoot: string;
270
+ rewrites?: Array<{source: string; destination: string}>;
139
271
  // For customizing the routes and adding your own...
140
- beforeRoutesAdded?: (app: HSApp) => void;
141
- afterRoutesAdded?: (app: HSApp) => void;
272
+ beforeRoutesAdded?: (app: Hono) => void;
273
+ afterRoutesAdded?: (app: Hono) => void;
142
274
  };
143
275
 
144
276
  export type THSRouteMap = {
@@ -154,7 +286,8 @@ const ROUTE_SEGMENT = /(\[[a-zA-Z_\.]+\])/g;
154
286
  export async function buildRoutes(config: THSServerConfig): Promise<THSRouteMap[]> {
155
287
  // Walk all pages and add them as routes
156
288
  const routesDir = join(config.appDir, 'routes');
157
- const files = await readdir(routesDir, { recursive: true });
289
+ console.log(routesDir);
290
+ const files = await readdir(routesDir, {recursive: true});
158
291
  const routes: THSRouteMap[] = [];
159
292
 
160
293
  for (const file of files) {
@@ -176,7 +309,7 @@ export async function buildRoutes(config: THSServerConfig): Promise<THSRouteMap[
176
309
 
177
310
  if (dynamicPaths) {
178
311
  params = [];
179
- route = route.replace(ROUTE_SEGMENT, (match: string, p1: string, offset: number) => {
312
+ route = route.replace(ROUTE_SEGMENT, (match: string) => {
180
313
  const paramName = match.replace(/[^a-zA-Z_\.]+/g, '');
181
314
 
182
315
  if (match.includes('...')) {
@@ -202,15 +335,11 @@ export async function buildRoutes(config: THSServerConfig): Promise<THSRouteMap[
202
335
  /**
203
336
  * Create and start Bun HTTP server
204
337
  */
205
- export async function createServer(config: THSServerConfig): Promise<HSApp> {
338
+ export async function createServer(config: THSServerConfig): Promise<Hono> {
206
339
  // Build client JS and CSS bundles so they are available for templates when streaming starts
207
340
  await Promise.all([buildClientJS(), buildClientCSS()]);
208
341
 
209
- const app = new HSApp();
210
-
211
- app.defaultRoute(() => {
212
- return new Response('Not... found?', { status: 404 });
213
- });
342
+ const app = new Hono();
214
343
 
215
344
  // [Customization] Before routes added...
216
345
  config.beforeRoutesAdded && config.beforeRoutesAdded(app);
@@ -224,15 +353,28 @@ export async function createServer(config: THSServerConfig): Promise<HSApp> {
224
353
  const fullRouteFile = join(CWD, route.file);
225
354
  const routePattern = normalizePath(route.route);
226
355
 
227
- routeMap.push({ route: routePattern, file: route.file });
356
+ routeMap.push({route: routePattern, file: route.file});
357
+
358
+ // Import route
359
+ const routeModule = await import(fullRouteFile);
228
360
 
229
361
  app.all(routePattern, async (context) => {
230
- const matchedRoute = await runFileRoute(fullRouteFile, context);
362
+ const matchedRoute = await runFileRoute(routeModule, context);
231
363
  if (matchedRoute) {
232
364
  return matchedRoute as Response;
233
365
  }
234
366
 
235
- return app._defaultRoute(context);
367
+ return context.notFound();
368
+ });
369
+ }
370
+
371
+ // Help route if no routes found
372
+ if (routeMap.length === 0) {
373
+ app.get('/', (context) => {
374
+ return context.text(
375
+ 'No routes found. Add routes to app/routes. Example: `app/routes/index.ts`',
376
+ {status: 404}
377
+ );
236
378
  });
237
379
  }
238
380
 
@@ -246,80 +388,26 @@ export async function createServer(config: THSServerConfig): Promise<HSApp> {
246
388
  config.afterRoutesAdded && config.afterRoutesAdded(app);
247
389
 
248
390
  // Static files and catchall
249
- app.all('*', (context) => {
250
- const req = context.req;
251
-
252
- // Static files
253
- if (STATIC_FILE_MATCHER.test(req.url)) {
254
- const filePath = config.staticFileRoot + new URL(req.url).pathname;
255
- const file = Bun.file(filePath);
256
- let headers = {};
257
-
258
- if (IS_PROD) {
259
- headers = {
260
- 'cache-control': 'public, max-age=31557600',
261
- };
262
- }
263
-
264
- return new Response(file, { headers });
265
- }
266
-
267
- return app._defaultRoute(context);
391
+ app.use(
392
+ '*',
393
+ serveStatic({
394
+ root: config.staticFileRoot,
395
+ })
396
+ );
397
+
398
+ app.notFound((context) => {
399
+ return context.text('Not... found?', {status: 404});
268
400
  });
269
401
 
270
402
  return app;
271
403
  }
272
404
 
273
- /**
274
- * Build client JS for end users (minimal JS for Hyperspan to work)
275
- */
276
- export let clientJSFile: string;
277
- export async function buildClientJS() {
278
- const sourceFile = resolve(PWD, '../', './src/clientjs/hyperspan-client.ts');
279
- const output = await Bun.build({
280
- entrypoints: [sourceFile],
281
- outdir: `./public/_hs/js`,
282
- naming: IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined,
283
- minify: IS_PROD,
284
- });
285
-
286
- clientJSFile = output.outputs[0].path.split('/').reverse()[0];
287
- return clientJSFile;
288
- }
289
-
290
- /**
291
- * Find client CSS file built for end users
292
- * @TODO: Build this in code here vs. relying on tailwindcss CLI tool from package scripts
293
- */
294
- export let clientCSSFile: string;
295
- export async function buildClientCSS() {
296
- if (clientCSSFile) {
297
- return clientCSSFile;
298
- }
299
-
300
- // Find file already built from tailwindcss CLI
301
- const cssDir = './public/_hs/css/';
302
- const cssFiles = await readdir(cssDir);
303
-
304
- for (const file of cssFiles) {
305
- // Only looking for CSS files
306
- if (clientCSSFile || !file.endsWith('.css')) {
307
- continue;
308
- }
309
-
310
- return (clientCSSFile = file.replace(cssDir, ''));
311
- }
312
-
313
- if (!clientCSSFile) {
314
- throw new Error(`Unable to build CSS files from ${cssDir}`);
315
- }
316
- }
317
-
318
405
  /**
319
406
  * Streaming HTML Response
320
407
  */
321
- export class StreamResponse {
408
+ export class StreamResponse extends Response {
322
409
  constructor(iterator: AsyncIterator<unknown>, options = {}) {
410
+ super();
323
411
  const stream = createReadableStreamFromAsyncGenerator(iterator as AsyncGenerator);
324
412
 
325
413
  return new Response(stream, {
@@ -343,7 +431,7 @@ export function createReadableStreamFromAsyncGenerator(output: AsyncGenerator) {
343
431
  return new ReadableStream({
344
432
  async start(controller) {
345
433
  while (true) {
346
- const { done, value } = await output.next();
434
+ const {done, value} = await output.next();
347
435
 
348
436
  if (done) {
349
437
  controller.close();
@@ -357,19 +445,12 @@ export function createReadableStreamFromAsyncGenerator(output: AsyncGenerator) {
357
445
  }
358
446
 
359
447
  /**
360
- * Form route
361
- * Automatically handles and parses form data
362
- *
363
- * 1. Renders component as initial form markup
364
- * 2. Bind form onSubmit function to custom client JS handling
365
- * 3. Submits form with JavaScript fetch()
366
- * 4. Replaces form content with content from server
367
- * 5. All validation and save logic is on the server
368
- * 6. Handles any Exception thrown on server as error displayed in client
448
+ * Normalize URL path
449
+ * Removes trailing slash and lowercases path
369
450
  */
370
- export type TFormRouteFn = (context: HSRequestContext) => HSTemplate | Response;
371
- export function formRoute(handlerFn: TFormRouteFn) {
372
- return function _formRouteHandler(context: HSRequestContext) {
373
- // @TODO: Parse form data and pass it into form route handler
374
- };
451
+ export function normalizePath(urlPath: string): string {
452
+ return (
453
+ (urlPath.endsWith('/') ? urlPath.substring(0, urlPath.length - 1) : urlPath).toLowerCase() ||
454
+ '/'
455
+ );
375
456
  }
package/.prettierrc DELETED
@@ -1,7 +0,0 @@
1
- {
2
- "printWidth": 100,
3
- "trailingComma": "es5",
4
- "tabWidth": 2,
5
- "semi": true,
6
- "singleQuote": true
7
- }
package/bun.lockb DELETED
Binary file