@hyperspan/framework 1.0.0-alpha.3 → 1.0.0-alpha.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspan/framework",
3
- "version": "1.0.0-alpha.3",
3
+ "version": "1.0.0-alpha.5",
4
4
  "description": "Hyperspan Web Framework",
5
5
  "main": "src/server.ts",
6
6
  "types": "src/server.ts",
@@ -10,8 +10,8 @@
10
10
  },
11
11
  "exports": {
12
12
  ".": {
13
- "types": "./src/server.ts",
14
- "default": "./src/server.ts"
13
+ "types": "./src/index.ts",
14
+ "default": "./src/index.ts"
15
15
  },
16
16
  "./server": {
17
17
  "types": "./src/server.ts",
@@ -80,7 +80,7 @@
80
80
  "typescript": "^5.9.3"
81
81
  },
82
82
  "dependencies": {
83
- "@hyperspan/html": "0.2.0",
83
+ "@hyperspan/html": "^1.0.0-alpha",
84
84
  "zod": "^4.1.12"
85
85
  }
86
86
  }
package/src/actions.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { html, HSHtml } from '@hyperspan/html';
2
- import { createRoute, parsePath, returnHTMLResponse } from './server';
2
+ import { createRoute, returnHTMLResponse } from './server';
3
3
  import * as z from 'zod/v4';
4
4
  import type { Hyperspan as HS } from './types';
5
- import { assetHash } from './utils';
5
+ import { assetHash, formDataToJSON } from './utils';
6
+ import * as actionsClient from './client/_hs/hyperspan-actions.client';
7
+ import { renderClientJS } from './client/js';
6
8
 
7
9
  /**
8
10
  * Actions = Form + route handler
@@ -16,45 +18,15 @@ import { assetHash } from './utils';
16
18
  * 4. All validation and save logic is run on the server
17
19
  * 5. Replaces form content in place with HTML response content from server via the Idiomorph library
18
20
  * 6. Handles any Exception thrown on server as error displayed back to user on the page
19
- */;
20
- type HSActionResponse = HSHtml | void | null | Promise<HSHtml | void | null> | Response | Promise<Response>;
21
- export type HSFormHandler<T extends z.ZodTypeAny> = (
22
- c: HS.Context,
23
- { data, error }: { data?: Partial<z.infer<T>>; error?: z.ZodError | Error }
24
- ) => HSActionResponse;
25
- export interface HSAction<T extends z.ZodTypeAny> {
26
- _kind: 'hsAction';
27
- _name: string;
28
- _path(): string;
29
- _form: null | HSFormHandler<T>;
30
- form(form: HSFormHandler<T>): HSAction<T>;
31
- post(
32
- handler: (
33
- c: HS.Context,
34
- { data }: { data?: Partial<z.infer<T>> }
35
- ) => HSActionResponse
36
- ): HSAction<T>;
37
- error(
38
- handler: (
39
- c: HS.Context,
40
- { data, error }: { data?: Partial<z.infer<T>>; error?: z.ZodError | Error }
41
- ) => HSActionResponse
42
- ): HSAction<T>;
43
- render(
44
- c: HS.Context,
45
- props?: { data?: Partial<z.infer<T>>; error?: z.ZodError | Error }
46
- ): HSActionResponse;
47
- middleware: (middleware: Array<HS.MiddlewareFunction>) => HSAction<T>;
48
- fetch(request: Request): Response | Promise<Response>;
49
- }
50
-
51
- export function createAction<T extends z.ZodTypeAny>(params: { name: string; schema?: T }) {
21
+ */
22
+ export function createAction<T extends z.ZodTypeAny>(params: { name: string; schema?: T }): HS.Action<T> {
52
23
  const { name, schema } = params;
24
+ const path = `/__actions/${assetHash(name)}`;
53
25
 
54
- let _handler: Parameters<HSAction<T>['post']>[0] | null = null;
55
- let _errorHandler: Parameters<HSAction<T>['error']>[0] | null = null;
26
+ let _handler: Parameters<HS.Action<T>['post']>[0] | null = null;
27
+ let _errorHandler: Parameters<HS.Action<T>['errorHandler']>[0] | null = null;
56
28
 
57
- const route = createRoute()
29
+ const route = createRoute({ path, name })
58
30
  .get((c: HS.Context) => api.render(c))
59
31
  .post(async (c: HS.Context) => {
60
32
  // Parse form data
@@ -99,18 +71,18 @@ export function createAction<T extends z.ZodTypeAny>(params: { name: string; sch
99
71
  return await returnHTMLResponse(c, () => api.render(c, { data, error }), { status: 400 });
100
72
  });
101
73
 
102
- const api: HSAction<T> = {
74
+ const api: HS.Action<T> = {
103
75
  _kind: 'hsAction',
104
- _name: name,
76
+ _config: route._config,
105
77
  _path() {
106
- return `/__actions/${assetHash(name)}`;
78
+ return path;
107
79
  },
108
80
  _form: null,
109
81
  /**
110
82
  * Form to render
111
83
  * This will be wrapped in a <hs-action> web component and submitted via fetch()
112
84
  */
113
- form(form: HSFormHandler<T>) {
85
+ form(form: HS.ActionFormHandler<T>) {
114
86
  api._form = form;
115
87
  return api;
116
88
  },
@@ -125,102 +97,22 @@ export function createAction<T extends z.ZodTypeAny>(params: { name: string; sch
125
97
  return api;
126
98
  },
127
99
  /**
128
- * Cusotm error handler if you want to display something other than the default
100
+ * Get form renderer method
129
101
  */
130
- error(handler) {
102
+ render(c: HS.Context, props?: HS.ActionProps<T>) {
103
+ const formContent = api._form ? api._form(c, props || {}) : null;
104
+ return formContent ? html`<hs-action url="${this._path()}">${formContent}</hs-action>${renderClientJS(actionsClient)}` : null;
105
+ },
106
+ errorHandler(handler) {
131
107
  _errorHandler = handler;
132
108
  return api;
133
109
  },
134
- /**
135
- * Add middleware specific to this route
136
- */
137
- middleware(middleware) {
110
+ middleware(middleware: Array<HS.MiddlewareFunction>) {
138
111
  route.middleware(middleware);
139
112
  return api;
140
113
  },
141
- /**
142
- * Get form renderer method
143
- */
144
- render(c: HS.Context, props?: { data?: Partial<z.infer<T>>; error?: z.ZodError | Error }) {
145
- const formContent = api._form ? api._form(c, props || {}) : null;
146
- return formContent ? html`<hs-action url="${this._path()}">${formContent}</hs-action>` : null;
147
- },
148
- /**
149
- * Run action route handler
150
- */
151
- fetch(request: Request) {
152
- return route.fetch(request);
153
- },
114
+ fetch: route.fetch,
154
115
  };
155
116
 
156
117
  return api;
157
- }
158
-
159
- /**
160
- * Return JSON data structure for a given FormData object
161
- * Accounts for array fields (e.g. name="options[]" or <select multiple>)
162
- *
163
- * @link https://stackoverflow.com/a/75406413
164
- */
165
- export function formDataToJSON(formData: FormData): Record<string, string | string[]> {
166
- let object = {};
167
-
168
- /**
169
- * Parses FormData key xxx`[x][x][x]` fields into array
170
- */
171
- const parseKey = (key: string) => {
172
- const subKeyIdx = key.indexOf('[');
173
-
174
- if (subKeyIdx !== -1) {
175
- const keys = [key.substring(0, subKeyIdx)];
176
- key = key.substring(subKeyIdx);
177
-
178
- for (const match of key.matchAll(/\[(?<key>.*?)]/gm)) {
179
- if (match.groups) {
180
- keys.push(match.groups.key);
181
- }
182
- }
183
- return keys;
184
- } else {
185
- return [key];
186
- }
187
- };
188
-
189
- /**
190
- * Recursively iterates over keys and assigns key/values to object
191
- */
192
- const assign = (keys: string[], value: FormDataEntryValue, object: any): void => {
193
- const key = keys.shift();
194
-
195
- // When last key in the iterations
196
- if (key === '' || key === undefined) {
197
- return object.push(value);
198
- }
199
-
200
- if (Reflect.has(object, key)) {
201
- // If key has been found, but final pass - convert the value to array
202
- if (keys.length === 0) {
203
- if (!Array.isArray(object[key])) {
204
- object[key] = [object[key], value];
205
- return;
206
- }
207
- }
208
- // Recurse again with found object
209
- return assign(keys, value, object[key]);
210
- }
211
-
212
- // Create empty object for key, if next key is '' do array instead, otherwise set value
213
- if (keys.length >= 1) {
214
- object[key] = keys[0] === '' ? [] : {};
215
- return assign(keys, value, object[key]);
216
- } else {
217
- object[key] = value;
218
- }
219
- };
220
-
221
- for (const pair of formData.entries()) {
222
- assign(parseKey(pair[0]), pair[1], object);
223
- }
224
-
225
- return object;
226
118
  }
@@ -0,0 +1,98 @@
1
+ import { Idiomorph } from './idiomorph';
2
+ import { lazyLoadScripts } from './hyperspan-scripts.client';
3
+
4
+ const actionFormObserver = new MutationObserver((list) => {
5
+ list.forEach((mutation) => {
6
+ mutation.addedNodes.forEach((node) => {
7
+ if (node && ('closest' in node || node instanceof HTMLFormElement)) {
8
+ bindHSActionForm(
9
+ (node as HTMLElement).closest('hs-action') as HSAction,
10
+ node instanceof HTMLFormElement
11
+ ? node
12
+ : ((node as HTMLElement | HTMLFormElement).querySelector('form') as HTMLFormElement)
13
+ );
14
+ }
15
+ });
16
+ });
17
+ });
18
+
19
+ /**
20
+ * Server action component to handle the client-side form submission and HTML replacement
21
+ */
22
+ class HSAction extends HTMLElement {
23
+ constructor() {
24
+ super();
25
+ }
26
+
27
+ connectedCallback() {
28
+ actionFormObserver.observe(this, { childList: true, subtree: true });
29
+ bindHSActionForm(this, this.querySelector('form') as HTMLFormElement);
30
+ }
31
+ }
32
+ window.customElements.define('hs-action', HSAction);
33
+
34
+ /**
35
+ * Bind the form inside an hs-action element to the action URL and submit handler
36
+ */
37
+ function bindHSActionForm(hsActionElement: HSAction, form: HTMLFormElement) {
38
+ if (!hsActionElement || !form) {
39
+ return;
40
+ }
41
+
42
+ form.setAttribute('action', hsActionElement.getAttribute('url') || '');
43
+ const submitHandler = (e: Event) => {
44
+ e.preventDefault();
45
+ formSubmitToRoute(e, form as HTMLFormElement, {
46
+ afterResponse: () => bindHSActionForm(hsActionElement, form),
47
+ });
48
+ form.removeEventListener('submit', submitHandler);
49
+ };
50
+ form.addEventListener('submit', submitHandler);
51
+ }
52
+
53
+ /**
54
+ * Submit form data to route and replace contents with response
55
+ */
56
+ type TFormSubmitOptons = { afterResponse: () => any };
57
+ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOptons) {
58
+ const formData = new FormData(form);
59
+ const formUrl = form.getAttribute('action') || '';
60
+ const method = form.getAttribute('method')?.toUpperCase() || 'POST';
61
+ const headers = {
62
+ Accept: 'text/html',
63
+ 'X-Request-Type': 'partial',
64
+ };
65
+
66
+ const hsActionTag = form.closest('hs-action');
67
+ const submitBtn = form.querySelector('button[type=submit],input[type=submit]');
68
+ if (submitBtn) {
69
+ submitBtn.setAttribute('disabled', 'disabled');
70
+ }
71
+
72
+ fetch(formUrl, { body: formData, method, headers })
73
+ .then((res: Response) => {
74
+ // Look for special header that indicates a redirect.
75
+ // fetch() automatically follows 3xx redirects, so we need to handle this manually to redirect the user to the full page
76
+ if (res.headers.has('X-Redirect-Location')) {
77
+ const newUrl = res.headers.get('X-Redirect-Location');
78
+ if (newUrl) {
79
+ window.location.assign(newUrl);
80
+ }
81
+ return '';
82
+ }
83
+
84
+ return res.text();
85
+ })
86
+ .then((content: string) => {
87
+ // No content = DO NOTHING (redirect or something else happened)
88
+ if (!content) {
89
+ return;
90
+ }
91
+
92
+ const target = content.includes('<html') ? window.document.body : hsActionTag || form;
93
+
94
+ Idiomorph.morph(target, content);
95
+ opts.afterResponse && opts.afterResponse();
96
+ lazyLoadScripts();
97
+ });
98
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Intersection observer for lazy loading <script> tags
3
+ */
4
+ const lazyLoadScriptObserver = new IntersectionObserver(
5
+ (entries, observer) => {
6
+ entries
7
+ .filter((entry) => entry.isIntersecting)
8
+ .forEach((entry) => {
9
+ observer.unobserve(entry.target);
10
+ // @ts-ignore
11
+ if (entry.target.children[0]?.content) {
12
+ // @ts-ignore
13
+ entry.target.replaceWith(entry.target.children[0].content);
14
+ }
15
+ });
16
+ },
17
+ { rootMargin: '0px 0px -200px 0px' }
18
+ );
19
+
20
+ /**
21
+ * Lazy load <script> tags in the current document
22
+ */
23
+ export function lazyLoadScripts() {
24
+ document
25
+ .querySelectorAll('div[data-loading=lazy]')
26
+ .forEach((el) => lazyLoadScriptObserver.observe(el));
27
+ }
28
+
29
+ window.addEventListener('load', () => {
30
+ lazyLoadScripts();
31
+ });
@@ -0,0 +1,94 @@
1
+ import { Idiomorph } from './idiomorph';
2
+ import { lazyLoadScripts } from './hyperspan-scripts.client';
3
+
4
+ /**
5
+ * Used for streaming content from the server to the client.
6
+ */
7
+ function htmlAsyncContentObserver() {
8
+ if (typeof MutationObserver != 'undefined') {
9
+ // Hyperspan - Async content loader
10
+ // Puts streamed content in its place immediately after it is added to the DOM
11
+ const asyncContentObserver = new MutationObserver((list) => {
12
+ const asyncContent = list
13
+ .map((mutation) =>
14
+ Array.from(mutation.addedNodes).find((node: any) => {
15
+ if (!node || !node?.id || typeof node.id !== 'string') {
16
+ return false;
17
+ }
18
+ return node.id?.startsWith('async_loading_') && node.id?.endsWith('_content');
19
+ })
20
+ )
21
+ .filter((node: any) => node);
22
+
23
+ asyncContent.forEach((templateEl: any) => {
24
+ try {
25
+ // Also observe for content inside the template content (shadow DOM is separate)
26
+ asyncContentObserver.observe(templateEl.content, { childList: true, subtree: true });
27
+
28
+ const slotId = templateEl.id.replace('_content', '');
29
+ const slotEl = document.getElementById(slotId);
30
+
31
+ if (slotEl) {
32
+ // Content AND slot are present - let's insert the content into the slot
33
+ // Ensure the content is fully done streaming in before inserting it into the slot
34
+ waitForContent(templateEl.content, (el2) => {
35
+ return Array.from(el2.childNodes).find(
36
+ (node) => node.nodeType === Node.COMMENT_NODE && node.nodeValue === 'end'
37
+ );
38
+ })
39
+ .then((endComment) => {
40
+ templateEl.content.removeChild(endComment);
41
+ const content = templateEl.content.cloneNode(true);
42
+ Idiomorph.morph(slotEl, content);
43
+ templateEl.parentNode.removeChild(templateEl);
44
+ lazyLoadScripts();
45
+ })
46
+ .catch(console.error);
47
+ } else {
48
+ // Slot is NOT present - wait for it to be added to the DOM so we can insert the content into it
49
+ waitForContent(document.body, () => {
50
+ return document.getElementById(slotId);
51
+ }).then((slotEl) => {
52
+ Idiomorph.morph(slotEl, templateEl.content.cloneNode(true));
53
+ lazyLoadScripts();
54
+ });
55
+ }
56
+ } catch (e) {
57
+ console.error(e);
58
+ }
59
+ });
60
+ });
61
+ asyncContentObserver.observe(document.body, { childList: true, subtree: true });
62
+ }
63
+ }
64
+ htmlAsyncContentObserver();
65
+
66
+ /**
67
+ * Wait until ALL of the content inside an element is present from streaming in.
68
+ * Large chunks of content can sometimes take more than a single tick to write to DOM.
69
+ */
70
+ async function waitForContent(
71
+ el: HTMLElement,
72
+ waitFn: (
73
+ node: HTMLElement
74
+ ) => HTMLElement | HTMLTemplateElement | Node | ChildNode | null | undefined,
75
+ options: { timeoutMs?: number; intervalMs?: number } = { timeoutMs: 10000, intervalMs: 20 }
76
+ ): Promise<HTMLElement | HTMLTemplateElement | Node | ChildNode | null | undefined> {
77
+ return new Promise((resolve, reject) => {
78
+ let timeout: NodeJS.Timeout;
79
+ const interval = setInterval(() => {
80
+ const content = waitFn(el);
81
+ if (content) {
82
+ if (timeout) {
83
+ clearTimeout(timeout);
84
+ }
85
+ clearInterval(interval);
86
+ resolve(content);
87
+ }
88
+ }, options.intervalMs || 20);
89
+ timeout = setTimeout(() => {
90
+ clearInterval(interval);
91
+ reject(new Error(`[Hyperspan] Timeout waiting for end of streaming content ${el.id}`));
92
+ }, options.timeoutMs || 10000);
93
+ });
94
+ }
package/src/client/js.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { html } from '@hyperspan/html';
2
- import type { Hyperspan as HS } from '../types';
3
2
 
4
3
  export const JS_PUBLIC_PATH = '/_hs/js';
5
4
  export const JS_ISLAND_PUBLIC_PATH = '/_hs/js/islands';
@@ -59,23 +58,4 @@ export function functionToString(fn: any) {
59
58
  }
60
59
 
61
60
  return str;
62
- }
63
-
64
- /**
65
- * Island defaults
66
- */
67
- export const ISLAND_DEFAULTS: () => HS.ClientIslandOptions = () => ({
68
- ssr: true,
69
- loading: undefined,
70
- });
71
-
72
- export function renderIsland(Component: any, props: any, options = ISLAND_DEFAULTS()) {
73
- // Render island with its own logic
74
- if (Component.__HS_ISLAND?.render) {
75
- return html.raw(Component.__HS_ISLAND.render(props, options));
76
- }
77
-
78
- throw new Error(
79
- `Module ${Component.name} was not loaded with an island plugin! Did you forget to install an island plugin and add it to the 'islandPlugins' option in your hyperspan.config.ts file?`
80
- );
81
61
  }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { createConfig, createContext, createRoute, createServer, getRunnableRoute, StreamResponse } from './server';
2
+ export type { Hyperspan } from './types';
package/src/plugins.ts CHANGED
@@ -46,6 +46,7 @@ export function clientJSPlugin(): HS.Plugin {
46
46
  const esmName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', '');
47
47
  JS_IMPORT_MAP.set(esmName, `${JS_PUBLIC_PATH}/${esmName}.js`);
48
48
 
49
+ // Get the contents of the file to extract the exports
49
50
  const contents = await result.outputs[0].text();
50
51
  const exportLine = EXPORT_REGEX.exec(contents);
51
52
 
@@ -67,9 +68,6 @@ export function clientJSPlugin(): HS.Plugin {
67
68
  const moduleCode = `// hyperspan:processed
68
69
  import { functionToString } from '@hyperspan/framework/client/js';
69
70
 
70
- // Original file contents
71
- ${contents}
72
-
73
71
  // hyperspan:client-js-plugin
74
72
  export const __CLIENT_JS = {
75
73
  id: "${jsId}",
package/src/server.ts CHANGED
@@ -2,7 +2,6 @@ import { HSHtml, html, isHSHtml, renderStream, renderAsync, render } from '@hype
2
2
  import { executeMiddleware } from './middleware';
3
3
  import type { Hyperspan as HS } from './types';
4
4
  import { clientJSPlugin } from './plugins';
5
- import { CSS_ROUTE_MAP } from './client/css';
6
5
  export type { HS as Hyperspan };
7
6
 
8
7
  export const IS_PROD = process.env.NODE_ENV === 'production';
@@ -50,7 +49,10 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
50
49
  method,
51
50
  headers,
52
51
  query,
53
- body: req.body,
52
+ async text() { return req.text() },
53
+ async json<T = unknown>() { return await req.json() as T },
54
+ async formData<T = unknown>() { return await req.formData() as T },
55
+ async urlencoded() { return new URLSearchParams(await req.text()) },
54
56
  },
55
57
  res: {
56
58
  headers: new Headers(),
@@ -135,6 +137,10 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
135
137
  _middleware['OPTIONS'] = handlerOptions?.middleware || [];
136
138
  return api;
137
139
  },
140
+ errorHandler(handler: HS.RouteHandler) {
141
+ _handlers['_ERROR'] = handler;
142
+ return api;
143
+ },
138
144
  /**
139
145
  * Add middleware specific to this route
140
146
  */
@@ -196,7 +202,16 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
196
202
  return routeContent;
197
203
  };
198
204
 
199
- return executeMiddleware(context, [...globalMiddleware, ...methodMiddleware, methodHandler]);
205
+ // Run the route handler and any middleware
206
+ // If an error occurs, run the error handler if it exists
207
+ try {
208
+ return await executeMiddleware(context, [...globalMiddleware, ...methodMiddleware, methodHandler]);
209
+ } catch (e) {
210
+ if (_handlers['_ERROR']) {
211
+ return await (_handlers['_ERROR'](context) as Promise<Response>);
212
+ }
213
+ throw e;
214
+ }
200
215
  },
201
216
  };
202
217
 
@@ -329,11 +344,6 @@ export function getRunnableRoute(route: unknown, routeConfig?: HS.RouteConfig):
329
344
 
330
345
  const kind = typeof route;
331
346
 
332
- // Plain function - wrap in createRoute()
333
- if (kind === 'function') {
334
- return createRoute(routeConfig).get(route as HS.RouteHandler);
335
- }
336
-
337
347
  // Module - get default and use it
338
348
  // @ts-ignore
339
349
  if (kind === 'object' && 'default' in route) {
@@ -355,7 +365,7 @@ export function isRunnableRoute(route: unknown): boolean {
355
365
  }
356
366
 
357
367
  const obj = route as { _kind: string; fetch: (request: Request) => Promise<Response> };
358
- return 'hsRoute' === obj?._kind && 'fetch' in obj;
368
+ return typeof obj?._kind === 'string' && 'fetch' in obj;
359
369
  }
360
370
 
361
371
  /**
@@ -365,7 +375,7 @@ export function isValidRoutePath(path: string): boolean {
365
375
  const isHiddenRoute = path.includes('/__');
366
376
  const isTestFile = path.includes('.test') || path.includes('.spec');
367
377
 
368
- return !isHiddenRoute && !isTestFile;
378
+ return !isHiddenRoute && !isTestFile && Boolean(path);
369
379
  }
370
380
 
371
381
  /**
package/src/types.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { HSHtml } from '@hyperspan/html';
2
+ import * as z from 'zod/v4';
3
+
1
4
  /**
2
5
  * Hyperspan Types
3
6
  */
@@ -39,7 +42,10 @@ export namespace Hyperspan {
39
42
  method: string; // Always uppercase
40
43
  headers: Headers; // Case-insensitive
41
44
  query: URLSearchParams;
42
- body: any;
45
+ text: () => Promise<string>;
46
+ json<T = unknown>(): Promise<T>;
47
+ formData<T = unknown>(): Promise<T>;
48
+ urlencoded(): Promise<URLSearchParams>;
43
49
  };
44
50
  res: {
45
51
  headers: Headers; // Headers to merge with final outgoing response
@@ -94,7 +100,6 @@ export namespace Hyperspan {
94
100
 
95
101
  export interface Route {
96
102
  _kind: 'hsRoute';
97
- _name: string | undefined;
98
103
  _config: Hyperspan.RouteConfig;
99
104
  _path(): string;
100
105
  _methods(): string[];
@@ -104,7 +109,29 @@ export namespace Hyperspan {
104
109
  patch: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
105
110
  delete: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
106
111
  options: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
112
+ errorHandler: (handler: Hyperspan.RouteHandler) => Hyperspan.Route;
107
113
  middleware: (middleware: Array<Hyperspan.MiddlewareFunction>) => Hyperspan.Route;
108
114
  fetch: (request: Request) => Promise<Response>;
109
115
  };
116
+
117
+ /**
118
+ * Action = Form + route handler
119
+ */
120
+ export type ActionResponse = HSHtml | void | null | Promise<HSHtml | void | null> | Response | Promise<Response>;
121
+ export type ActionProps<T extends z.ZodTypeAny> = { data?: Partial<z.infer<T>>; error?: z.ZodError | Error };
122
+ export type ActionFormHandler<T extends z.ZodTypeAny> = (
123
+ c: Context, props: ActionProps<T>
124
+ ) => ActionResponse;
125
+ export interface Action<T extends z.ZodTypeAny> {
126
+ _kind: 'hsAction';
127
+ _config: Hyperspan.RouteConfig;
128
+ _path(): string;
129
+ _form: null | ActionFormHandler<T>;
130
+ form(form: ActionFormHandler<T>): Action<T>;
131
+ render: (c: Context, props?: ActionProps<T>) => ActionResponse;
132
+ post: (handler: ActionFormHandler<T>) => Action<T>;
133
+ errorHandler: (handler: ActionFormHandler<T>) => Action<T>;
134
+ middleware: (middleware: Array<Hyperspan.MiddlewareFunction>) => Action<T>;
135
+ fetch: (request: Request) => Promise<Response>;
136
+ }
110
137
  }
package/src/utils.ts CHANGED
@@ -6,4 +6,73 @@ export function assetHash(content: string): string {
6
6
 
7
7
  export function randomHash(): string {
8
8
  return createHash('md5').update(randomBytes(32).toString('hex')).digest('hex');
9
+ }
10
+
11
+ /**
12
+ * Return JSON data structure for a given FormData or URLSearchParams object
13
+ * Accounts for array fields (e.g. name="options[]" or <select multiple>)
14
+ *
15
+ * @link https://stackoverflow.com/a/75406413
16
+ */
17
+ export function formDataToJSON(formData: FormData | URLSearchParams): Record<string, string | string[]> {
18
+ let object = {};
19
+
20
+ /**
21
+ * Parses FormData key xxx`[x][x][x]` fields into array
22
+ */
23
+ const parseKey = (key: string) => {
24
+ const subKeyIdx = key.indexOf('[');
25
+
26
+ if (subKeyIdx !== -1) {
27
+ const keys = [key.substring(0, subKeyIdx)];
28
+ key = key.substring(subKeyIdx);
29
+
30
+ for (const match of key.matchAll(/\[(?<key>.*?)]/gm)) {
31
+ if (match.groups) {
32
+ keys.push(match.groups.key);
33
+ }
34
+ }
35
+ return keys;
36
+ } else {
37
+ return [key];
38
+ }
39
+ };
40
+
41
+ /**
42
+ * Recursively iterates over keys and assigns key/values to object
43
+ */
44
+ const assign = (keys: string[], value: FormDataEntryValue, object: any): void => {
45
+ const key = keys.shift();
46
+
47
+ // When last key in the iterations
48
+ if (key === '' || key === undefined) {
49
+ return object.push(value);
50
+ }
51
+
52
+ if (Reflect.has(object, key)) {
53
+ // If key has been found, but final pass - convert the value to array
54
+ if (keys.length === 0) {
55
+ if (!Array.isArray(object[key])) {
56
+ object[key] = [object[key], value];
57
+ return;
58
+ }
59
+ }
60
+ // Recurse again with found object
61
+ return assign(keys, value, object[key]);
62
+ }
63
+
64
+ // Create empty object for key, if next key is '' do array instead, otherwise set value
65
+ if (keys.length >= 1) {
66
+ object[key] = keys[0] === '' ? [] : {};
67
+ return assign(keys, value, object[key]);
68
+ } else {
69
+ object[key] = value;
70
+ }
71
+ };
72
+
73
+ for (const pair of formData.entries()) {
74
+ assign(parseKey(pair[0]), pair[1], object);
75
+ }
76
+
77
+ return object;
9
78
  }
@@ -1,224 +0,0 @@
1
- import { html } from '@hyperspan/html';
2
- import { Idiomorph } from './idiomorph';
3
-
4
- /**
5
- * Used for streaming content from the server to the client.
6
- */
7
- function htmlAsyncContentObserver() {
8
- if (typeof MutationObserver != 'undefined') {
9
- // Hyperspan - Async content loader
10
- // Puts streamed content in its place immediately after it is added to the DOM
11
- const asyncContentObserver = new MutationObserver((list) => {
12
- const asyncContent = list
13
- .map((mutation) =>
14
- Array.from(mutation.addedNodes).find((node: any) => {
15
- if (!node || !node?.id || typeof node.id !== 'string') {
16
- return false;
17
- }
18
- return node.id?.startsWith('async_loading_') && node.id?.endsWith('_content');
19
- })
20
- )
21
- .filter((node: any) => node);
22
-
23
- asyncContent.forEach((templateEl: any) => {
24
- try {
25
- // Also observe for content inside the template content (shadow DOM is separate)
26
- asyncContentObserver.observe(templateEl.content, { childList: true, subtree: true });
27
-
28
- const slotId = templateEl.id.replace('_content', '');
29
- const slotEl = document.getElementById(slotId);
30
-
31
- if (slotEl) {
32
- // Content AND slot are present - let's insert the content into the slot
33
- // Ensure the content is fully done streaming in before inserting it into the slot
34
- waitForContent(templateEl.content, (el2) => {
35
- return Array.from(el2.childNodes).find(
36
- (node) => node.nodeType === Node.COMMENT_NODE && node.nodeValue === 'end'
37
- );
38
- })
39
- .then((endComment) => {
40
- templateEl.content.removeChild(endComment);
41
- const content = templateEl.content.cloneNode(true);
42
- Idiomorph.morph(slotEl, content);
43
- templateEl.parentNode.removeChild(templateEl);
44
- lazyLoadScripts();
45
- })
46
- .catch(console.error);
47
- } else {
48
- // Slot is NOT present - wait for it to be added to the DOM so we can insert the content into it
49
- waitForContent(document.body, () => {
50
- return document.getElementById(slotId);
51
- }).then((slotEl) => {
52
- Idiomorph.morph(slotEl, templateEl.content.cloneNode(true));
53
- lazyLoadScripts();
54
- });
55
- }
56
- } catch (e) {
57
- console.error(e);
58
- }
59
- });
60
- });
61
- asyncContentObserver.observe(document.body, { childList: true, subtree: true });
62
- }
63
- }
64
- htmlAsyncContentObserver();
65
-
66
- /**
67
- * Wait until ALL of the content inside an element is present from streaming in.
68
- * Large chunks of content can sometimes take more than a single tick to write to DOM.
69
- */
70
- async function waitForContent(
71
- el: HTMLElement,
72
- waitFn: (
73
- node: HTMLElement
74
- ) => HTMLElement | HTMLTemplateElement | Node | ChildNode | null | undefined,
75
- options: { timeoutMs?: number; intervalMs?: number } = { timeoutMs: 10000, intervalMs: 20 }
76
- ): Promise<HTMLElement | HTMLTemplateElement | Node | ChildNode | null | undefined> {
77
- return new Promise((resolve, reject) => {
78
- let timeout: NodeJS.Timeout;
79
- const interval = setInterval(() => {
80
- const content = waitFn(el);
81
- if (content) {
82
- if (timeout) {
83
- clearTimeout(timeout);
84
- }
85
- clearInterval(interval);
86
- resolve(content);
87
- }
88
- }, options.intervalMs || 20);
89
- timeout = setTimeout(() => {
90
- clearInterval(interval);
91
- reject(new Error(`[Hyperspan] Timeout waiting for end of streaming content ${el.id}`));
92
- }, options.timeoutMs || 10000);
93
- });
94
- }
95
-
96
- /**
97
- * Server action component to handle the client-side form submission and HTML replacement
98
- */
99
- class HSAction extends HTMLElement {
100
- constructor() {
101
- super();
102
- }
103
-
104
- connectedCallback() {
105
- actionFormObserver.observe(this, { childList: true, subtree: true });
106
- bindHSActionForm(this, this.querySelector('form') as HTMLFormElement);
107
- }
108
- }
109
- window.customElements.define('hs-action', HSAction);
110
- const actionFormObserver = new MutationObserver((list) => {
111
- list.forEach((mutation) => {
112
- mutation.addedNodes.forEach((node) => {
113
- if (node && ('closest' in node || node instanceof HTMLFormElement)) {
114
- bindHSActionForm(
115
- (node as HTMLElement).closest('hs-action') as HSAction,
116
- node instanceof HTMLFormElement
117
- ? node
118
- : ((node as HTMLElement | HTMLFormElement).querySelector('form') as HTMLFormElement)
119
- );
120
- }
121
- });
122
- });
123
- });
124
-
125
- /**
126
- * Bind the form inside an hs-action element to the action URL and submit handler
127
- */
128
- function bindHSActionForm(hsActionElement: HSAction, form: HTMLFormElement) {
129
- if (!hsActionElement || !form) {
130
- return;
131
- }
132
-
133
- form.setAttribute('action', hsActionElement.getAttribute('url') || '');
134
- const submitHandler = (e: Event) => {
135
- e.preventDefault();
136
- formSubmitToRoute(e, form as HTMLFormElement, {
137
- afterResponse: () => bindHSActionForm(hsActionElement, form),
138
- });
139
- form.removeEventListener('submit', submitHandler);
140
- };
141
- form.addEventListener('submit', submitHandler);
142
- }
143
-
144
- /**
145
- * Submit form data to route and replace contents with response
146
- */
147
- type TFormSubmitOptons = { afterResponse: () => any };
148
- function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOptons) {
149
- const formData = new FormData(form);
150
- const formUrl = form.getAttribute('action') || '';
151
- const method = form.getAttribute('method')?.toUpperCase() || 'POST';
152
- const headers = {
153
- Accept: 'text/html',
154
- 'X-Request-Type': 'partial',
155
- };
156
-
157
- const hsActionTag = form.closest('hs-action');
158
- const submitBtn = form.querySelector('button[type=submit],input[type=submit]');
159
- if (submitBtn) {
160
- submitBtn.setAttribute('disabled', 'disabled');
161
- }
162
-
163
- fetch(formUrl, { body: formData, method, headers })
164
- .then((res: Response) => {
165
- // Look for special header that indicates a redirect.
166
- // fetch() automatically follows 3xx redirects, so we need to handle this manually to redirect the user to the full page
167
- if (res.headers.has('X-Redirect-Location')) {
168
- const newUrl = res.headers.get('X-Redirect-Location');
169
- if (newUrl) {
170
- window.location.assign(newUrl);
171
- }
172
- return '';
173
- }
174
-
175
- return res.text();
176
- })
177
- .then((content: string) => {
178
- // No content = DO NOTHING (redirect or something else happened)
179
- if (!content) {
180
- return;
181
- }
182
-
183
- const target = content.includes('<html') ? window.document.body : hsActionTag || form;
184
-
185
- Idiomorph.morph(target, content);
186
- opts.afterResponse && opts.afterResponse();
187
- lazyLoadScripts();
188
- });
189
- }
190
-
191
- /**
192
- * Intersection observer for lazy loading <script> tags
193
- */
194
- const lazyLoadScriptObserver = new IntersectionObserver(
195
- (entries, observer) => {
196
- entries
197
- .filter((entry) => entry.isIntersecting)
198
- .forEach((entry) => {
199
- observer.unobserve(entry.target);
200
- // @ts-ignore
201
- if (entry.target.children[0]?.content) {
202
- // @ts-ignore
203
- entry.target.replaceWith(entry.target.children[0].content);
204
- }
205
- });
206
- },
207
- { rootMargin: '0px 0px -200px 0px' }
208
- );
209
-
210
- /**
211
- * Lazy load <script> tags in the current document
212
- */
213
- function lazyLoadScripts() {
214
- document
215
- .querySelectorAll('div[data-loading=lazy]')
216
- .forEach((el) => lazyLoadScriptObserver.observe(el));
217
- }
218
-
219
- window.addEventListener('load', () => {
220
- lazyLoadScripts();
221
- });
222
-
223
- // @ts-ignore
224
- window.html = html;
File without changes