@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/.prettierrc +7 -0
- package/README.md +83 -0
- package/bun.lockb +0 -0
- package/dist/index.js +468 -0
- package/dist/server.js +1935 -0
- package/package.json +38 -0
- package/src/app.ts +186 -0
- package/src/clientjs/hyperspan-client.ts +218 -0
- package/src/clientjs/idomorph.esm.js +854 -0
- package/src/clientjs/md5.js +176 -0
- package/src/document.ts +10 -0
- package/src/forms.ts +110 -0
- package/src/html.test.ts +69 -0
- package/src/html.ts +342 -0
- package/src/index.ts +14 -0
- package/src/server.ts +366 -0
- package/tsconfig.json +26 -0
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
|
+
}
|