@hyperspan/framework 0.0.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/index.ts ADDED
@@ -0,0 +1,14 @@
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';
package/src/server.ts ADDED
@@ -0,0 +1,366 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { basename, extname, join } 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';
7
+
8
+ export const IS_PROD = process.env.NODE_ENV === 'production';
9
+ const STATIC_FILE_MATCHER = /[^/\\&\?]+\.([a-zA-Z]+)$/;
10
+
11
+ // Cached route components
12
+ const _routeCache: { [key: string]: any } = {};
13
+
14
+ /**
15
+ * Did request come from a bot?
16
+ */
17
+ function requestIsBot(req: Request) {
18
+ const ua = req.headers.get('User-Agent');
19
+
20
+ return ua ? isbot(ua) : false;
21
+ }
22
+
23
+ /**
24
+ * Run route from file
25
+ */
26
+ export async function runFileRoute(routeFile: string, context: HSRequestContext) {
27
+ const req = context.req;
28
+ const url = new URL(req.url);
29
+ const qs = url.searchParams;
30
+
31
+ // @TODO: Move this to config or something...
32
+ const streamOpt = qs.get('__nostream') ? !Boolean(qs.get('__nostream')) : undefined;
33
+ const streamingEnabled = streamOpt !== undefined ? streamOpt : true;
34
+
35
+ // Import route component
36
+ const RouteModule = _routeCache[routeFile] || (await import(routeFile));
37
+
38
+ if (IS_PROD) {
39
+ // Only cache routes in prod
40
+ _routeCache[routeFile] = RouteModule;
41
+ }
42
+
43
+ // Route module
44
+ const RouteComponent = RouteModule.default;
45
+ const reqMethod = req.method.toUpperCase();
46
+
47
+ // Middleware?
48
+ const routeMiddleware = RouteModule.middleware || {}; // Example: { auth: apiAuth, logger: logMiddleware, }
49
+ const middlewareResult: any = {};
50
+
51
+ try {
52
+ // Run middleware if present...
53
+ if (Object.keys(routeMiddleware).length) {
54
+ for (const mKey in routeMiddleware) {
55
+ const mRes = await routeMiddleware[mKey](context);
56
+
57
+ if (mRes instanceof Response) {
58
+ return context.resMerge(mRes);
59
+ }
60
+
61
+ middlewareResult[mKey] = mRes;
62
+ }
63
+ }
64
+
65
+ // API Route?
66
+ if (RouteModule[reqMethod] !== undefined) {
67
+ return await runAPIRoute(RouteModule[reqMethod], context, middlewareResult);
68
+ }
69
+
70
+ // Route component
71
+ const routeContent = await RouteComponent(context, middlewareResult);
72
+
73
+ if (routeContent instanceof Response) {
74
+ return context.resMerge(routeContent);
75
+ }
76
+
77
+ if (streamingEnabled && !requestIsBot(req)) {
78
+ return context.resMerge(new StreamResponse(renderToStream(routeContent)) as Response);
79
+ } else {
80
+ // Render content and template
81
+ // TODO: Use any context variables from RouteComponent rendering to set values in layout (dynamic title, etc.)...
82
+ const output = await renderToString(routeContent);
83
+
84
+ // Render it...
85
+ return context.html(output);
86
+ }
87
+ } catch (e) {
88
+ console.error(e);
89
+ return await showErrorReponse(context, e as Error);
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Run route and handle response
95
+ */
96
+ async function runAPIRoute(routeFn: any, context: HSRequestContext, middlewareResult?: any) {
97
+ try {
98
+ return await routeFn(context, middlewareResult);
99
+ } catch (err) {
100
+ const e = err as Error;
101
+ console.error(e);
102
+
103
+ return context.json(
104
+ {
105
+ meta: { success: false },
106
+ data: {
107
+ message: e.message,
108
+ stack: IS_PROD ? undefined : e.stack?.split('\n'),
109
+ },
110
+ },
111
+ { status: 500 }
112
+ );
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Basic error handling
118
+ * @TODO: Should check for and load user-customizeable template with special name (app/__error.ts ?)
119
+ */
120
+ async function showErrorReponse(context: HSRequestContext, err: Error) {
121
+ const output = await renderToString(html`
122
+ <main>
123
+ <h1>Error</h1>
124
+ <pre>${err.message}</pre>
125
+ <pre>${!IS_PROD && err.stack ? err.stack.split('\n').slice(1).join('\n') : ''}</pre>
126
+ </main>
127
+ `);
128
+
129
+ return context.html(output, {
130
+ status: 500,
131
+ });
132
+ }
133
+
134
+ export type THSServerConfig = {
135
+ appDir: string;
136
+ staticFileRoot: string;
137
+ // For customizing the routes and adding your own...
138
+ beforeRoutesAdded?: (app: HSApp) => void;
139
+ afterRoutesAdded?: (app: HSApp) => void;
140
+ };
141
+
142
+ export type THSRouteMap = {
143
+ file: string;
144
+ route: string;
145
+ params: string[];
146
+ };
147
+
148
+ /**
149
+ * Build routes
150
+ */
151
+ const ROUTE_SEGMENT = /(\[[a-zA-Z_\.]+\])/g;
152
+ export async function buildRoutes(config: THSServerConfig): Promise<THSRouteMap[]> {
153
+ // Walk all pages and add them as routes
154
+ const routesDir = join(config.appDir, 'routes');
155
+ const files = await readdir(routesDir, { recursive: true });
156
+ const routes: THSRouteMap[] = [];
157
+
158
+ for (const file of files) {
159
+ // No directories
160
+ if (!file.includes('.') || basename(file).startsWith('.')) {
161
+ continue;
162
+ }
163
+
164
+ let route = '/' + file.replace(extname(file), '');
165
+
166
+ // Index files
167
+ if (route.endsWith('index')) {
168
+ route = route === 'index' ? '/' : route.substring(0, route.length - 6);
169
+ }
170
+
171
+ // Dynamic params
172
+ let params: string[] = [];
173
+ const dynamicPaths = ROUTE_SEGMENT.test(route);
174
+
175
+ if (dynamicPaths) {
176
+ params = [];
177
+ route = route.replace(ROUTE_SEGMENT, (match: string, p1: string, offset: number) => {
178
+ const paramName = match.replace(/[^a-zA-Z_\.]+/g, '');
179
+
180
+ if (match.includes('...')) {
181
+ params.push(paramName.replace('...', ''));
182
+ return '*';
183
+ } else {
184
+ params.push(paramName);
185
+ return ':' + paramName;
186
+ }
187
+ });
188
+ }
189
+
190
+ routes.push({
191
+ file,
192
+ route: route || '/',
193
+ params,
194
+ });
195
+ }
196
+
197
+ return routes;
198
+ }
199
+
200
+ /**
201
+ * Create and start Bun HTTP server
202
+ */
203
+ export async function createServer(config: THSServerConfig): Promise<HSApp> {
204
+ // Build client JS and CSS bundles so they are available for templates when streaming starts
205
+ await Promise.all([buildClientJS(), buildClientCSS()]);
206
+
207
+ const app = new HSApp();
208
+
209
+ app.defaultRoute(() => {
210
+ return new Response('Not... found?', { status: 404 });
211
+ });
212
+
213
+ // [Customization] Before routes added...
214
+ config.beforeRoutesAdded && config.beforeRoutesAdded(app);
215
+
216
+ // Scan routes folder and add all file routes to the router
217
+ const fileRoutes = await buildRoutes(config);
218
+
219
+ for (let i = 0; i < fileRoutes.length; i++) {
220
+ let route = fileRoutes[i];
221
+ const fullRouteFile = join('../../', config.appDir, 'routes', route.file);
222
+ const routePattern = normalizePath(route.route);
223
+
224
+ // @ts-ignore
225
+ console.log('[Hyperspan] Added route: ', routePattern);
226
+
227
+ app.all(routePattern, async (context) => {
228
+ const matchedRoute = await runFileRoute(fullRouteFile, context);
229
+ if (matchedRoute) {
230
+ return matchedRoute as Response;
231
+ }
232
+
233
+ return app._defaultRoute(context);
234
+ });
235
+ }
236
+
237
+ // [Customization] After routes added...
238
+ config.afterRoutesAdded && config.afterRoutesAdded(app);
239
+
240
+ // Static files and catchall
241
+ app.all('*', (context) => {
242
+ const req = context.req;
243
+
244
+ // Static files
245
+ if (STATIC_FILE_MATCHER.test(req.url)) {
246
+ const filePath = config.staticFileRoot + new URL(req.url).pathname;
247
+ const file = Bun.file(filePath);
248
+ let headers = {};
249
+
250
+ if (IS_PROD) {
251
+ headers = {
252
+ 'cache-control': 'public, max-age=31557600',
253
+ };
254
+ }
255
+
256
+ return new Response(file, { headers });
257
+ }
258
+
259
+ return app._defaultRoute(context);
260
+ });
261
+
262
+ return app;
263
+ }
264
+
265
+ /**
266
+ * Build client JS for end users (minimal JS for Hyperspan to work)
267
+ */
268
+ export let clientJSFile: string;
269
+ export async function buildClientJS() {
270
+ const output = await Bun.build({
271
+ entrypoints: ['./src/hyperspan/clientjs/hyperspan-client.ts'],
272
+ outdir: `./public/_hs/js`,
273
+ naming: IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined,
274
+ minify: IS_PROD,
275
+ });
276
+
277
+ clientJSFile = output.outputs[0].path.split('/').reverse()[0];
278
+ return clientJSFile;
279
+ }
280
+
281
+ /**
282
+ * Find client CSS file built for end users
283
+ * @TODO: Build this in code here vs. relying on tailwindcss CLI tool from package scripts
284
+ */
285
+ export let clientCSSFile: string;
286
+ export async function buildClientCSS() {
287
+ if (clientCSSFile) {
288
+ return clientCSSFile;
289
+ }
290
+
291
+ // Find file already built from tailwindcss CLI
292
+ const cssDir = './public/_hs/css/';
293
+ const cssFiles = await readdir(cssDir);
294
+
295
+ for (const file of cssFiles) {
296
+ // Only looking for CSS files
297
+ if (clientCSSFile || !file.endsWith('.css')) {
298
+ continue;
299
+ }
300
+
301
+ return (clientCSSFile = file.replace(cssDir, ''));
302
+ }
303
+
304
+ if (!clientCSSFile) {
305
+ throw new Error(`Unable to build CSS files from ${cssDir}`);
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Streaming HTML Response
311
+ */
312
+ export class StreamResponse {
313
+ constructor(iterator: AsyncIterator<unknown>, options = {}) {
314
+ const stream = createReadableStreamFromAsyncGenerator(iterator as AsyncGenerator);
315
+
316
+ return new Response(stream, {
317
+ status: 200,
318
+ headers: {
319
+ 'Content-Type': 'text/html',
320
+ 'Transfer-Encoding': 'chunked',
321
+ // @ts-ignore
322
+ ...(options?.headers ?? {}),
323
+ },
324
+ ...options,
325
+ });
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Does what it says on the tin...
331
+ */
332
+ export function createReadableStreamFromAsyncGenerator(output: AsyncGenerator) {
333
+ const encoder = new TextEncoder();
334
+ return new ReadableStream({
335
+ async start(controller) {
336
+ while (true) {
337
+ const { done, value } = await output.next();
338
+
339
+ if (done) {
340
+ controller.close();
341
+ break;
342
+ }
343
+
344
+ controller.enqueue(encoder.encode(value as unknown as string));
345
+ }
346
+ },
347
+ });
348
+ }
349
+
350
+ /**
351
+ * Form route
352
+ * Automatically handles and parses form data
353
+ *
354
+ * 1. Renders component as initial form markup
355
+ * 2. Bind form onSubmit function to custom client JS handling
356
+ * 3. Submits form with JavaScript fetch()
357
+ * 4. Replaces form content with content from server
358
+ * 5. All validation and save logic is on the server
359
+ * 6. Handles any Exception thrown on server as error displayed in client
360
+ */
361
+ export type TFormRouteFn = (context: HSRequestContext) => HSTemplate | Response;
362
+ export function formRoute(handlerFn: TFormRouteFn) {
363
+ return function _formRouteHandler(context: HSRequestContext) {
364
+ // @TODO: Parse form data and pass it into form route handler
365
+ };
366
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "compileOnSave": true,
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist",
6
+ "target": "es2019",
7
+ "lib": ["ESNext", "dom", "dom.iterable"],
8
+ "module": "esnext",
9
+ "moduleResolution": "bundler",
10
+ "moduleDetection": "force",
11
+ "esModuleInterop": true,
12
+ "allowImportingTsExtensions": true,
13
+ "noEmit": true,
14
+ "composite": true,
15
+ "strict": true,
16
+ "declaration": true,
17
+ "sourceMap": true,
18
+ "downlevelIteration": true,
19
+ "skipLibCheck": true,
20
+ "allowSyntheticDefaultImports": true,
21
+ "forceConsistentCasingInFileNames": true,
22
+ "allowJs": true,
23
+ "types": ["bun-types"]
24
+ },
25
+ "exclude": ["node_modules", "__tests__", "*.test.ts"]
26
+ }