@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/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;
|