@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/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@hyperspan/framework",
3
+ "version": "0.0.1",
4
+ "description": "Hyperspan Web Framework",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "public": true,
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "scripts": {
12
+ "build": "bun run clean && bun run build:client && bun run build:server",
13
+ "build:client": "bun build ./src/index.ts --outdir ./dist --target browser",
14
+ "build:server": "bun build ./src/server.ts --outdir ./dist --target bun",
15
+ "clean": "rm -rf dist",
16
+ "test": "bun test",
17
+ "prepack": "npm run clean && npm run build"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/vlucas/hyperspan-framework"
22
+ },
23
+ "keywords": ["framework", "node", "bun", "web", "framework", "javascript", "typescript"],
24
+ "author": "Vance Lucas <vance@vancelucas.com>",
25
+ "license": "BSD-3-Clause",
26
+ "bugs": {
27
+ "url": "https://github.com/vlucas/hyperspan-framework/issues"
28
+ },
29
+ "homepage": "https://www.hyperspan.dev",
30
+ "dependencies": {
31
+ "@fastify/deepmerge": "^2.0.0",
32
+ "@mjackson/headers": "^0.7.2",
33
+ "escape-html": "^1.0.3",
34
+ "isbot": "^5.1.17",
35
+ "trek-middleware": "^1.2.0",
36
+ "trek-router": "^1.2.0"
37
+ }
38
+ }
package/src/app.ts ADDED
@@ -0,0 +1,186 @@
1
+ // @ts-ignore
2
+ import Router from 'trek-router';
3
+ // @ts-ignore
4
+ import Middleware from 'trek-middleware';
5
+ import deepmerge from '@fastify/deepmerge';
6
+ import Headers from '@mjackson/headers';
7
+
8
+ const mergeAll = deepmerge({ all: true });
9
+
10
+ type THTTPMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
11
+
12
+ /**
13
+ * Request context
14
+ */
15
+ export class HSRequestContext {
16
+ public req: Request;
17
+ public locals: Record<string, any>;
18
+ public headers: Headers;
19
+ public route: {
20
+ params: Record<string, string>;
21
+ query: URLSearchParams;
22
+ };
23
+
24
+ constructor(req: Request, params: Record<string, string> = {}) {
25
+ this.req = req;
26
+ this.locals = {};
27
+ this.route = {
28
+ params,
29
+ query: new URL(req.url).searchParams,
30
+ };
31
+
32
+ // This could probably be re-visited...
33
+ const allowHeaders = ['cookie'];
34
+ const reqHeaders: Record<string, string> = {};
35
+
36
+ for (let [name, value] of req.headers) {
37
+ if (allowHeaders.includes(name)) {
38
+ reqHeaders[name] = value;
39
+ }
40
+ }
41
+
42
+ this.headers = new Headers(reqHeaders);
43
+ }
44
+
45
+ /**
46
+ * Response helper
47
+ * Merges a Response object while preserving all headers added in context/middleware
48
+ */
49
+ resMerge(res: Response) {
50
+ const cHeaders: Record<string, string> = {};
51
+ for (let [name, value] of this.headers) {
52
+ cHeaders[name] = value;
53
+ }
54
+
55
+ const newRes = new Response(
56
+ res.body,
57
+ mergeAll(
58
+ { headers: cHeaders },
59
+ { headers: res.headers.toJSON() },
60
+ { status: res.status, statusText: res.statusText }
61
+ )
62
+ );
63
+
64
+ return newRes;
65
+ }
66
+
67
+ /**
68
+ * HTML response helper
69
+ * Preserves all headers added in context/middleware
70
+ */
71
+ html(content: string, options?: ResponseInit): Response {
72
+ return new Response(content, mergeAll({ headers: { 'Content-Type': 'text/html' } }, options));
73
+ }
74
+
75
+ /**
76
+ * JSON response helper
77
+ * Preserves all headers added in context/middleware
78
+ */
79
+ json(content: any, options?: ResponseInit): Response {
80
+ return new Response(
81
+ JSON.stringify(content),
82
+ mergeAll({ headers: { 'Content-Type': 'application/json' } }, options)
83
+ );
84
+ }
85
+
86
+ notFound(msg: string = 'Not found!') {
87
+ return this.html(msg, { status: 404 });
88
+ }
89
+ }
90
+
91
+ type THSRouteHandler = (
92
+ context: HSRequestContext
93
+ ) => (Response | null | void) | Promise<Response | null | void>;
94
+
95
+ /**
96
+ * App
97
+ */
98
+ export class HSApp {
99
+ private _router: typeof Router;
100
+ private _mw: typeof Middleware;
101
+ public _defaultRoute: THSRouteHandler;
102
+
103
+ constructor() {
104
+ this._router = new Router();
105
+ this._mw = new Middleware();
106
+ this._defaultRoute = (c: HSRequestContext) => {
107
+ return c.notFound('Not found');
108
+ };
109
+ }
110
+
111
+ // @TODO: Middleware !!!!
112
+
113
+ public get(path: string, handler: THSRouteHandler) {
114
+ return this._route('GET', path, handler);
115
+ }
116
+ public post(path: string, handler: THSRouteHandler) {
117
+ return this._route('POST', path, handler);
118
+ }
119
+ public put(path: string, handler: THSRouteHandler) {
120
+ return this._route('PUT', path, handler);
121
+ }
122
+ public delete(path: string, handler: THSRouteHandler) {
123
+ return this._route('DELETE', path, handler);
124
+ }
125
+ public all(path: string, handler: THSRouteHandler) {
126
+ return this.addRoute(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], path, handler);
127
+ }
128
+ public addRoute(methods: THTTPMethod[], path: string, handler: THSRouteHandler) {
129
+ methods.forEach((method) => {
130
+ this._route(method, path, handler);
131
+ });
132
+ return this;
133
+ }
134
+ public defaultRoute(handler: THSRouteHandler) {
135
+ this._defaultRoute = handler;
136
+ }
137
+ private _route(method: string | string[], path: string, handler: any) {
138
+ this._router.add(method, path, handler);
139
+ return this;
140
+ }
141
+
142
+ async run(req: Request): Promise<Response> {
143
+ let response: Response;
144
+ let url = new URL(req.url);
145
+ let urlPath = normalizePath(url.pathname);
146
+
147
+ // Redirect to normalized path (lowercase & without trailing slash)
148
+ if (urlPath !== url.pathname) {
149
+ url.pathname = urlPath;
150
+ return Response.redirect(url);
151
+ }
152
+
153
+ let result = this._router.find(req.method.toUpperCase(), urlPath);
154
+ let params: Record<string, any> = {};
155
+
156
+ if (result && result[0]) {
157
+ // Build params
158
+ result[1].forEach((param: any) => (params[param.name] = param.value));
159
+
160
+ // Run route with context
161
+ const context = new HSRequestContext(req, params);
162
+ response = result[0](context);
163
+ }
164
+
165
+ // @ts-ignore
166
+ if (response) {
167
+ return response;
168
+ }
169
+
170
+ const context = new HSRequestContext(req);
171
+
172
+ // @ts-ignore
173
+ return this._defaultRoute(context);
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Normalize URL path
179
+ * Removes trailing slash and lowercases path
180
+ */
181
+ export function normalizePath(urlPath: string): string {
182
+ return (
183
+ (urlPath.endsWith('/') ? urlPath.substring(0, urlPath.length - 1) : urlPath).toLowerCase() ||
184
+ '/'
185
+ );
186
+ }
@@ -0,0 +1,218 @@
1
+ import { html, renderToString } from '../html';
2
+ import { Idiomorph } from './idomorph.esm';
3
+
4
+ function setupAsyncContentObserver() {
5
+ if (typeof MutationObserver != 'undefined') {
6
+ // Hyperspan - Async content loader
7
+ // Puts streamed content in its place immediately after it is added to the DOM
8
+ const asyncContentObserver = new MutationObserver((list) => {
9
+ const asyncContent = list
10
+ .map((mutation) =>
11
+ Array.from(mutation.addedNodes).find((node: any) => {
12
+ return node.id?.startsWith('async_') && node.id?.endsWith('_content');
13
+ })
14
+ )
15
+ .filter((node: any) => node);
16
+
17
+ asyncContent.forEach((el: any) => {
18
+ try {
19
+ // Also observe child nodes for nested async content
20
+ asyncContentObserver.observe(el.content, { childList: true, subtree: true });
21
+
22
+ const slotId = el.id.replace('_content', '');
23
+ const slotEl = document.getElementById(slotId);
24
+
25
+ if (slotEl) {
26
+ // Wait until next paint for streaming content to finish writing to DOM
27
+ // @TODO: Need a more guaranteed way to know HTML element is done streaming in...
28
+ // Maybe some ".end" element that is hidden and then removed before insertion?
29
+ requestAnimationFrame(() => {
30
+ setTimeout(() => {
31
+ Idiomorph.morph(slotEl, el.content.cloneNode(true));
32
+ el.parentNode.removeChild(el);
33
+ }, 100);
34
+ });
35
+ }
36
+ } catch (e) {
37
+ console.error(e);
38
+ }
39
+ });
40
+ });
41
+ asyncContentObserver.observe(document.body, { childList: true, subtree: true });
42
+ }
43
+ }
44
+ setupAsyncContentObserver();
45
+
46
+ /**
47
+ * Event binding for added/updated content
48
+ */
49
+ function setupEventBindingObserver() {
50
+ if (typeof MutationObserver != 'undefined') {
51
+ const eventBindingObserver = new MutationObserver((list) => {
52
+ bindHyperspanEvents(document.body);
53
+ });
54
+ eventBindingObserver.observe(document.body, { childList: true, subtree: true });
55
+ }
56
+ }
57
+ setupEventBindingObserver();
58
+
59
+ /**
60
+ * Global Window assignments...
61
+ */
62
+
63
+ // @ts-ignore
64
+ const hyperspan: any = {
65
+ _fn: new Map(),
66
+ wc: new Map(),
67
+ compIdOrLast(id?: string) {
68
+ let comp = hyperspan.wc.get(id);
69
+
70
+ // Get last component if id lookup failed
71
+ if (!comp) {
72
+ const lastComp = Array.from(hyperspan.wc).pop();
73
+ // @ts-ignore - The value returned from a Map is a tuple. The second value (lastComp[1]) is the actual value
74
+ comp = lastComp ? lastComp[1] : false;
75
+ }
76
+
77
+ return comp || false;
78
+ },
79
+ fn(id: string, ufn: any) {
80
+ const comp = this.compIdOrLast(id);
81
+
82
+ const fnd = {
83
+ id,
84
+ cid: comp ? comp.id : null,
85
+ fn: comp ? ufn.bind(comp) : ufn,
86
+ comp,
87
+ };
88
+
89
+ this._fn.set(id, fnd);
90
+ },
91
+ // Binds function execution to the component instance so 'this' keyword works as expected inside event handlers
92
+ fnc(id: string, ...args: any[]) {
93
+ const fnd = this._fn.get(id);
94
+
95
+ if (!fnd) {
96
+ console.log('[Hyperspan] Unable to find function with id ' + id);
97
+ return;
98
+ }
99
+
100
+ if (fnd.comp) {
101
+ fnd.fn.call(fnd.comp, ...args);
102
+ } else {
103
+ fnd.fn(...args);
104
+ }
105
+ },
106
+ };
107
+
108
+ /**
109
+ * Web component (foundation of client components)
110
+ */
111
+ class HyperspanComponent extends HTMLElement {
112
+ constructor() {
113
+ super();
114
+ }
115
+
116
+ static get observedAttributes() {
117
+ return ['data-state'];
118
+ }
119
+
120
+ randomId() {
121
+ return Math.random().toString(36).substring(2, 9);
122
+ }
123
+
124
+ async render() {
125
+ let content = '<div>Loading...</div>';
126
+
127
+ const comp = hyperspan.wc.get(this.id);
128
+
129
+ if (comp) {
130
+ content = await renderToString(comp.render());
131
+ }
132
+
133
+ Idiomorph.morph(this, content, { morphStyle: 'innerHTML' });
134
+ }
135
+
136
+ connectedCallback() {
137
+ const comp = hyperspan.wc.get(this.id);
138
+
139
+ if (comp) {
140
+ comp.mount && comp.mount();
141
+ }
142
+ }
143
+
144
+ attributeChangedCallback() {
145
+ this.render();
146
+ }
147
+ }
148
+
149
+ // Bind events
150
+ function bindHyperspanEvents(webComponentEl: HTMLElement) {
151
+ const domEvents = [
152
+ 'click',
153
+ 'dblclick',
154
+ 'contextmenu',
155
+ 'hover',
156
+ 'focus',
157
+ 'blur',
158
+ 'mouseup',
159
+ 'mousedown',
160
+ 'touchstart',
161
+ 'touchend',
162
+ 'touchcancel',
163
+ 'touchmove',
164
+ 'submit',
165
+ 'change',
166
+ 'scroll',
167
+ 'keyup',
168
+ 'keydown',
169
+ ];
170
+ const eventEls = Array.from(
171
+ webComponentEl.querySelectorAll('[on' + domEvents.join('], [on') + ']')
172
+ );
173
+
174
+ for (let i = 0; i < eventEls.length; i++) {
175
+ const el = eventEls[i] as HTMLElement;
176
+ const elEvents = el.getAttributeNames();
177
+
178
+ elEvents
179
+ .filter((ev) => ev.startsWith('on'))
180
+ .map((event) => {
181
+ const fnId = el.getAttribute(event)?.replace('hyperspan:', '');
182
+
183
+ if (fnId && el.dataset[event] !== fnId) {
184
+ const eventName = event.replace('on', '');
185
+ el.addEventListener(eventName, globalEventDispatch);
186
+ el.dataset[event] = fnId;
187
+ el.removeAttribute(event);
188
+ }
189
+ });
190
+ }
191
+ }
192
+
193
+ // Proxies all events to the function they go to by event type
194
+ function globalEventDispatch(e: Event) {
195
+ let el = e.target as HTMLElement;
196
+
197
+ if (el) {
198
+ const dataName = 'on' + e.type;
199
+ let fnId = el.dataset[dataName];
200
+
201
+ if (!fnId) {
202
+ el = el.closest('[data-' + dataName + ']') || el;
203
+ }
204
+
205
+ fnId = el.dataset[dataName];
206
+
207
+ if (fnId) {
208
+ hyperspan.fnc(fnId, e, el);
209
+ }
210
+ }
211
+ }
212
+
213
+ customElements.define('hs-wc', HyperspanComponent);
214
+
215
+ // @ts-ignore
216
+ window.hyperspan = hyperspan;
217
+ // @ts-ignore
218
+ window.html = html;