@hyperspan/framework 1.0.0-alpha.1 → 1.0.0-alpha.11

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.1",
3
+ "version": "1.0.0-alpha.11",
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",
@@ -21,10 +21,6 @@
21
21
  "types": "./src/middleware.ts",
22
22
  "default": "./src/middleware.ts"
23
23
  },
24
- "./middleware/zod": {
25
- "types": "./src/middleware/zod.ts",
26
- "default": "./src/middleware/zod.ts"
27
- },
28
24
  "./utils": {
29
25
  "types": "./src/utils.ts",
30
26
  "default": "./src/utils.ts"
@@ -44,6 +40,10 @@
44
40
  "./plugins": {
45
41
  "types": "./src/plugins.ts",
46
42
  "default": "./src/plugins.ts"
43
+ },
44
+ "./actions": {
45
+ "types": "./src/actions.ts",
46
+ "default": "./src/actions.ts"
47
47
  }
48
48
  },
49
49
  "author": "Vance Lucas <vance@vancelucas.com>",
@@ -76,7 +76,7 @@
76
76
  "typescript": "^5.9.3"
77
77
  },
78
78
  "dependencies": {
79
- "@hyperspan/html": "0.2.0",
79
+ "@hyperspan/html": "^1.0.0-alpha",
80
80
  "zod": "^4.1.12"
81
81
  }
82
82
  }
@@ -0,0 +1,147 @@
1
+ import { test, expect, describe } from 'bun:test';
2
+ import { createAction } from './actions';
3
+ import { html, render, type HSHtml } from '@hyperspan/html';
4
+ import { createContext } from './server';
5
+ import type { Hyperspan as HS } from './types';
6
+ import * as z from 'zod/v4';
7
+
8
+ describe('createAction', () => {
9
+ test('creates an action with a simple form and no schema', async () => {
10
+ const action = createAction({
11
+ name: 'test',
12
+ schema: z.object({
13
+ name: z.string().min(1, 'Name is required'),
14
+ }),
15
+ }).form((c) => {
16
+ return html`
17
+ <form>
18
+ <input type="text" name="name" />
19
+ <button type="submit">Submit</button>
20
+ </form>
21
+ `;
22
+ }).post(async (c, { data }) => {
23
+ return c.res.html(`
24
+ <p>Hello, ${data?.name}!</p>
25
+ `);
26
+ });
27
+
28
+ expect(action).toBeDefined();
29
+ expect(action._kind).toBe('hsAction');
30
+ expect(action._path()).toContain('/__actions/');
31
+
32
+ // Test render method
33
+ const request = new Request('http://localhost:3000/');
34
+ const context = createContext(request);
35
+ const rendered = render(action.render(context) as HSHtml);
36
+
37
+ expect(rendered).not.toBeNull();
38
+ const htmlString = rendered;
39
+ expect(htmlString).toContain('<hs-action');
40
+ expect(htmlString).toContain('name="name"');
41
+ });
42
+
43
+ test('creates an action with a Zod schema matching form inputs', async () => {
44
+ const schema = z.object({
45
+ name: z.string().min(1, 'Name is required'),
46
+ email: z.email('Invalid email address'),
47
+ });
48
+
49
+ const action = createAction({
50
+ name: 'test',
51
+ schema,
52
+ }).form((c, { data }) => {
53
+ return html`
54
+ <form>
55
+ <input type="text" name="name" />
56
+ <input type="email" name="email" />
57
+ <button type="submit">Submit</button>
58
+ </form>
59
+ `;
60
+ }).post(async (c, { data }) => {
61
+ return c.res.html(`
62
+ <p>Hello, ${data?.name}!</p>
63
+ <p>Your email is ${data?.email}.</p>
64
+ `);
65
+ });
66
+
67
+ expect(action).toBeDefined();
68
+ expect(action._kind).toBe('hsAction');
69
+ expect(action._path()).toContain('/__actions/');
70
+
71
+ // Test render method
72
+ const request = new Request('http://localhost:3000/');
73
+ const context = createContext(request);
74
+ const rendered = action.render(context);
75
+
76
+ expect(rendered).not.toBeNull();
77
+ const htmlString = render(rendered as unknown as HSHtml);
78
+ expect(htmlString).toContain('name="name"');
79
+ expect(htmlString).toContain('name="email"');
80
+
81
+ // Test fetch method with POST request to trigger validation
82
+ const formData = new FormData();
83
+ formData.append('name', 'John Doe');
84
+ formData.append('email', 'john@example.com');
85
+
86
+ const postRequest = new Request(`http://localhost:3000${action._path()}`, {
87
+ method: 'POST',
88
+ body: formData,
89
+ });
90
+
91
+ const response = await action.fetch(postRequest);
92
+ expect(response).toBeInstanceOf(Response);
93
+ expect(response.status).toBe(200);
94
+
95
+ const responseText = await response.text();
96
+ expect(responseText).toContain('Hello, John Doe!');
97
+ expect(responseText).toContain('Your email is john@example.com.');
98
+ });
99
+
100
+ test('re-renders form with error when schema validation fails', async () => {
101
+ const schema = z.object({
102
+ name: z.string().min(1, 'Name is required'),
103
+ email: z.email('Invalid email address'),
104
+ });
105
+
106
+ const action = createAction({
107
+ name: 'test',
108
+ schema,
109
+ }).form((c, { data, error }) => {
110
+ return html`
111
+ <form>
112
+ <input type="text" name="name" value="${data?.name || ''}" />
113
+ ${error ? html`<div class="error">Validation failed</div>` : ''}
114
+ <input type="email" name="email" value="${data?.email || ''}" />
115
+ <button type="submit">Submit</button>
116
+ </form>
117
+ `;
118
+ }).post(async (c, { data }) => {
119
+ return c.res.html(`
120
+ <p>Hello, ${data?.name}!</p>
121
+ <p>Your email is ${data?.email}.</p>
122
+ `);
123
+ });
124
+
125
+ // Test fetch method with invalid data (missing name, invalid email)
126
+ const formData = new FormData();
127
+ formData.append('email', 'not-an-email');
128
+
129
+ const postRequest = new Request(`http://localhost:3000${action._route}`, {
130
+ method: 'POST',
131
+ body: formData,
132
+ });
133
+
134
+ const response = await action.fetch(postRequest);
135
+ expect(response).toBeInstanceOf(Response);
136
+ expect(response.status).toBe(400);
137
+
138
+ const responseText = await response.text();
139
+ // Should re-render the form, not the post handler output
140
+ expect(responseText).toContain('name="name"');
141
+ expect(responseText).toContain('name="email"');
142
+ expect(responseText).toContain('Validation failed');
143
+ // Should NOT contain the success message from post handler
144
+ expect(responseText).not.toContain('Hello,');
145
+ });
146
+ });
147
+
package/src/actions.ts ADDED
@@ -0,0 +1,118 @@
1
+ import { html, HSHtml } from '@hyperspan/html';
2
+ import { createRoute, returnHTMLResponse } from './server';
3
+ import * as z from 'zod/v4';
4
+ import type { Hyperspan as HS } from './types';
5
+ import { assetHash, formDataToJSON } from './utils';
6
+ import * as actionsClient from './client/_hs/hyperspan-actions.client';
7
+ import { renderClientJS } from './client/js';
8
+
9
+ /**
10
+ * Actions = Form + route handler
11
+ * Automatically handles and parses form data
12
+ *
13
+ * HOW THIS WORKS:
14
+ * ---
15
+ * 1. Renders in any template as initial form markup with action.render()
16
+ * 2. Binds form onSubmit function to custom client JS handling via <hs-action> web component
17
+ * 3. Submits form with JavaScript fetch() + FormData as normal POST form submission
18
+ * 4. All validation and save logic is run on the server
19
+ * 5. Replaces form content in place with HTML response content from server via the Idiomorph library
20
+ * 6. Handles any Exception thrown on server as error displayed back to user on the page
21
+ */
22
+ export function createAction<T extends z.ZodTypeAny>(params: { name: string; schema?: T }): HS.Action<T> {
23
+ const { name, schema } = params;
24
+ const path = `/__actions/${assetHash(name)}`;
25
+
26
+ let _handler: Parameters<HS.Action<T>['post']>[0] | null = null;
27
+ let _errorHandler: Parameters<HS.Action<T>['errorHandler']>[0] | null = null;
28
+
29
+ const route = createRoute({ path, name })
30
+ .get((c: HS.Context) => api.render(c))
31
+ .post(async (c: HS.Context) => {
32
+ // Parse form data
33
+ const formData = await c.req.raw.formData();
34
+ const jsonData = formDataToJSON(formData) as Partial<z.infer<T>>;
35
+ const schemaData = schema ? schema.safeParse(jsonData) : null;
36
+ const data = schemaData?.success ? (schemaData.data as Partial<z.infer<T>>) : jsonData;
37
+ let error: z.ZodError | Error | null = null;
38
+
39
+ try {
40
+ if (schema && schemaData?.error) {
41
+ throw schemaData.error;
42
+ }
43
+
44
+ if (!_handler) {
45
+ throw new Error('Action POST handler not set! Every action must have a POST handler.');
46
+ }
47
+
48
+ const response = await _handler(c, { data });
49
+
50
+ if (response instanceof Response) {
51
+ // Replace redirects with special header because fetch() automatically follows redirects
52
+ // and we want to redirect the user to the actual full page instead
53
+ if ([301, 302, 307, 308].includes(response.status)) {
54
+ response.headers.set('X-Redirect-Location', response.headers.get('Location') || '/');
55
+ response.headers.delete('Location');
56
+ }
57
+ }
58
+
59
+ return response;
60
+ } catch (e) {
61
+ error = e as Error | z.ZodError;
62
+ }
63
+
64
+ if (error && _errorHandler) {
65
+ const errorHandler = _errorHandler; // Required for TypeScript to infer the correct type after narrowing
66
+ return await returnHTMLResponse(c, () => errorHandler(c, { data, error }), {
67
+ status: 400,
68
+ });
69
+ }
70
+
71
+ return await returnHTMLResponse(c, () => api.render(c, { data, error }), { status: 400 });
72
+ });
73
+
74
+ const api: HS.Action<T> = {
75
+ _kind: 'hsAction',
76
+ _config: route._config,
77
+ _path() {
78
+ return path;
79
+ },
80
+ _form: null,
81
+ /**
82
+ * Form to render
83
+ * This will be wrapped in a <hs-action> web component and submitted via fetch()
84
+ */
85
+ form(form: HS.ActionFormHandler<T>) {
86
+ api._form = form;
87
+ return api;
88
+ },
89
+ /**
90
+ * Process form data
91
+ *
92
+ * Returns result from form processing if successful
93
+ * Re-renders form with data and error information otherwise
94
+ */
95
+ post(handler) {
96
+ _handler = handler;
97
+ return api;
98
+ },
99
+ /**
100
+ * Get form renderer method
101
+ */
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) {
107
+ _errorHandler = handler;
108
+ return api;
109
+ },
110
+ middleware(middleware: Array<HS.MiddlewareFunction>) {
111
+ route.middleware(middleware);
112
+ return api;
113
+ },
114
+ fetch: route.fetch,
115
+ };
116
+
117
+ return api;
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,6 +1,4 @@
1
1
  import { html } from '@hyperspan/html';
2
- import type { Hyperspan as HS } from '../types';
3
-
4
2
 
5
3
  export const JS_PUBLIC_PATH = '/_hs/js';
6
4
  export const JS_ISLAND_PUBLIC_PATH = '/_hs/js/islands';
@@ -60,23 +58,4 @@ export function functionToString(fn: any) {
60
58
  }
61
59
 
62
60
  return str;
63
- }
64
-
65
- /**
66
- * Island defaults
67
- */
68
- export const ISLAND_DEFAULTS: () => HS.ClientIslandOptions = () => ({
69
- ssr: true,
70
- loading: undefined,
71
- });
72
-
73
- export function renderIsland(Component: any, props: any, options = ISLAND_DEFAULTS()) {
74
- // Render island with its own logic
75
- if (Component.__HS_ISLAND?.render) {
76
- return html.raw(Component.__HS_ISLAND.render(props, options));
77
- }
78
-
79
- throw new Error(
80
- `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?`
81
- );
82
61
  }