@hyperspan/framework 1.0.0-alpha.8 → 1.0.0

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.8",
3
+ "version": "1.0.0",
4
4
  "description": "Hyperspan Web Framework",
5
5
  "main": "src/server.ts",
6
6
  "types": "src/server.ts",
@@ -37,10 +37,6 @@
37
37
  "types": "./src/client/js.ts",
38
38
  "default": "./src/client/js.ts"
39
39
  },
40
- "./plugins": {
41
- "types": "./src/plugins.ts",
42
- "default": "./src/plugins.ts"
43
- },
44
40
  "./actions": {
45
41
  "types": "./src/actions.ts",
46
42
  "default": "./src/actions.ts"
@@ -76,7 +72,8 @@
76
72
  "typescript": "^5.9.3"
77
73
  },
78
74
  "dependencies": {
79
- "@hyperspan/html": "^1.0.0-alpha",
75
+ "@hyperspan/html": "^1.0.0",
76
+ "isbot": "^5.1.32",
80
77
  "zod": "^4.1.12"
81
78
  }
82
79
  }
@@ -1,92 +1,33 @@
1
1
  import { test, expect, describe } from 'bun:test';
2
- import { formDataToJSON, createAction } from './actions';
2
+ import { createAction } from './actions';
3
3
  import { html, render, type HSHtml } from '@hyperspan/html';
4
4
  import { createContext } from './server';
5
5
  import type { Hyperspan as HS } from './types';
6
6
  import * as z from 'zod/v4';
7
7
 
8
- describe('formDataToJSON', () => {
9
- test('formDataToJSON returns empty object for empty FormData', () => {
10
- const formData = new FormData();
11
- const result = formDataToJSON(formData);
12
-
13
- expect(result).toEqual({});
14
- });
15
-
16
- test('formDataToJSON handles simple FormData object', () => {
17
- const formData = new FormData();
18
- formData.append('name', 'John Doe');
19
- formData.append('email', 'john@example.com');
20
- formData.append('age', '30');
21
-
22
- const result = formDataToJSON(formData);
23
-
24
- expect(result).toEqual({
25
- name: 'John Doe',
26
- email: 'john@example.com',
27
- age: '30',
28
- });
29
- });
30
-
31
- test('formDataToJSON handles complex FormData with nested fields', () => {
32
- const formData = new FormData();
33
- formData.append('user[firstName]', 'John');
34
- formData.append('user[lastName]', 'Doe');
35
- formData.append('user[email]', 'john@example.com');
36
- formData.append('user[address][street]', '123 Main St');
37
- formData.append('user[address][city]', 'New York');
38
- formData.append('user[address][zip]', '10001');
39
-
40
- const result = formDataToJSON(formData);
41
-
42
- expect(result).toEqual({
43
- user: {
44
- firstName: 'John',
45
- lastName: 'Doe',
46
- email: 'john@example.com',
47
- address: {
48
- street: '123 Main St',
49
- city: 'New York',
50
- zip: '10001',
51
- },
52
- },
53
- } as any);
54
- });
55
-
56
- test('formDataToJSON handles FormData with array of values', () => {
57
- const formData = new FormData();
58
- formData.append('tags', 'javascript');
59
- formData.append('tags', 'typescript');
60
- formData.append('tags', 'nodejs');
61
- formData.append('colors[]', 'red');
62
- formData.append('colors[]', 'green');
63
- formData.append('colors[]', 'blue');
64
-
65
- const result = formDataToJSON(formData);
66
-
67
- expect(result).toEqual({
68
- tags: ['javascript', 'typescript', 'nodejs'],
69
- colors: ['red', 'green', 'blue'],
70
- });
71
- });
72
- });
73
-
74
8
  describe('createAction', () => {
75
9
  test('creates an action with a simple form and no schema', async () => {
76
10
  const action = createAction({
77
- form: (c: HS.Context) => {
78
- return html`
11
+ name: 'test',
12
+ schema: z.object({
13
+ name: z.string().min(1, 'Name is required'),
14
+ }),
15
+ }).form((c) => {
16
+ return html`
79
17
  <form>
80
18
  <input type="text" name="name" />
81
19
  <button type="submit">Submit</button>
82
20
  </form>
83
21
  `;
84
- },
22
+ }).post(async (c, { data }) => {
23
+ return c.res.html(`
24
+ <p>Hello, ${data?.name}!</p>
25
+ `);
85
26
  });
86
27
 
87
28
  expect(action).toBeDefined();
88
29
  expect(action._kind).toBe('hsAction');
89
- expect(action._route).toContain('/__actions/');
30
+ expect(action._path()).toContain('/__actions/');
90
31
 
91
32
  // Test render method
92
33
  const request = new Request('http://localhost:3000/');
@@ -106,17 +47,17 @@ describe('createAction', () => {
106
47
  });
107
48
 
108
49
  const action = createAction({
50
+ name: 'test',
109
51
  schema,
110
- form: (c: HS.Context, { data }) => {
111
- return html`
52
+ }).form((c, { data }) => {
53
+ return html`
112
54
  <form>
113
- <input type="text" name="name" value="${data?.name || ''}" />
114
- <input type="email" name="email" value="${data?.email || ''}" />
55
+ <input type="text" name="name" />
56
+ <input type="email" name="email" />
115
57
  <button type="submit">Submit</button>
116
58
  </form>
117
59
  `;
118
- },
119
- }).post(async (c: HS.Context, { data }) => {
60
+ }).post(async (c, { data }) => {
120
61
  return c.res.html(`
121
62
  <p>Hello, ${data?.name}!</p>
122
63
  <p>Your email is ${data?.email}.</p>
@@ -125,7 +66,7 @@ describe('createAction', () => {
125
66
 
126
67
  expect(action).toBeDefined();
127
68
  expect(action._kind).toBe('hsAction');
128
- expect(action._route).toContain('/__actions/');
69
+ expect(action._path()).toContain('/__actions/');
129
70
 
130
71
  // Test render method
131
72
  const request = new Request('http://localhost:3000/');
@@ -142,7 +83,7 @@ describe('createAction', () => {
142
83
  formData.append('name', 'John Doe');
143
84
  formData.append('email', 'john@example.com');
144
85
 
145
- const postRequest = new Request(`http://localhost:3000${action._route}`, {
86
+ const postRequest = new Request(`http://localhost:3000${action._path()}`, {
146
87
  method: 'POST',
147
88
  body: formData,
148
89
  });
@@ -163,9 +104,10 @@ describe('createAction', () => {
163
104
  });
164
105
 
165
106
  const action = createAction({
107
+ name: 'test',
166
108
  schema,
167
- form: (c: HS.Context, { data, error }) => {
168
- return html`
109
+ }).form((c, { data, error }) => {
110
+ return html`
169
111
  <form>
170
112
  <input type="text" name="name" value="${data?.name || ''}" />
171
113
  ${error ? html`<div class="error">Validation failed</div>` : ''}
@@ -173,8 +115,7 @@ describe('createAction', () => {
173
115
  <button type="submit">Submit</button>
174
116
  </form>
175
117
  `;
176
- },
177
- }).post(async (c: HS.Context, { data }) => {
118
+ }).post(async (c, { data }) => {
178
119
  return c.res.html(`
179
120
  <p>Hello, ${data?.name}!</p>
180
121
  <p>Your email is ${data?.email}.</p>
package/src/actions.ts CHANGED
@@ -1,10 +1,12 @@
1
- import { html, HSHtml } from '@hyperspan/html';
1
+ import { html } from '@hyperspan/html';
2
2
  import { createRoute, returnHTMLResponse } from './server';
3
3
  import * as z from 'zod/v4';
4
4
  import type { Hyperspan as HS } from './types';
5
5
  import { assetHash, formDataToJSON } from './utils';
6
- import * as actionsClient from './client/_hs/hyperspan-actions.client';
7
- import { renderClientJS } from './client/js';
6
+ import { buildClientJS } from './client/js';
7
+ import { validateBody } from './middleware';
8
+
9
+ const actionsClientJS = await buildClientJS(import.meta.resolve('./client/_hs/hyperspan-actions.client'));
8
10
 
9
11
  /**
10
12
  * Actions = Form + route handler
@@ -19,7 +21,7 @@ import { renderClientJS } from './client/js';
19
21
  * 5. Replaces form content in place with HTML response content from server via the Idiomorph library
20
22
  * 6. Handles any Exception thrown on server as error displayed back to user on the page
21
23
  */
22
- export function createAction<T extends z.ZodTypeAny>(params: { name: string; schema?: T }): HS.Action<T> {
24
+ export function createAction<T extends z.ZodObject<any, any>>(params: { name: string; schema?: T }): HS.Action<T> {
23
25
  const { name, schema } = params;
24
26
  const path = `/__actions/${assetHash(name)}`;
25
27
 
@@ -30,7 +32,7 @@ export function createAction<T extends z.ZodTypeAny>(params: { name: string; sch
30
32
  .get((c: HS.Context) => api.render(c))
31
33
  .post(async (c: HS.Context) => {
32
34
  // Parse form data
33
- const formData = await c.req.raw.formData();
35
+ const formData = await c.req.formData();
34
36
  const jsonData = formDataToJSON(formData) as Partial<z.infer<T>>;
35
37
  const schemaData = schema ? schema.safeParse(jsonData) : null;
36
38
  const data = schemaData?.success ? (schemaData.data as Partial<z.infer<T>>) : jsonData;
@@ -69,7 +71,10 @@ export function createAction<T extends z.ZodTypeAny>(params: { name: string; sch
69
71
  }
70
72
 
71
73
  return await returnHTMLResponse(c, () => api.render(c, { data, error }), { status: 400 });
72
- });
74
+ }, { middleware: schema ? [validateBody(schema)] : [] });
75
+
76
+ // Set the name of the action for the route
77
+ route._config.name = name;
73
78
 
74
79
  const api: HS.Action<T> = {
75
80
  _kind: 'hsAction',
@@ -101,7 +106,7 @@ export function createAction<T extends z.ZodTypeAny>(params: { name: string; sch
101
106
  */
102
107
  render(c: HS.Context, props?: HS.ActionProps<T>) {
103
108
  const formContent = api._form ? api._form(c, props || {}) : null;
104
- return formContent ? html`<hs-action url="${this._path()}">${formContent}</hs-action>${renderClientJS(actionsClient)}` : null;
109
+ return formContent ? html`<hs-action url="${this._path()}">${formContent}</hs-action>${actionsClientJS.renderScriptTag()}` : null;
105
110
  },
106
111
  errorHandler(handler) {
107
112
  _errorHandler = handler;
@@ -1,68 +1,5 @@
1
- import { Idiomorph } from './idiomorph';
2
1
  import { lazyLoadScripts } from './hyperspan-scripts.client';
3
2
 
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
3
  /**
67
4
  * Wait until ALL of the content inside an element is present from streaming in.
68
5
  * Large chunks of content can sometimes take more than a single tick to write to DOM.
@@ -91,4 +28,39 @@ async function waitForContent(
91
28
  reject(new Error(`[Hyperspan] Timeout waiting for end of streaming content ${el.id}`));
92
29
  }, options.timeoutMs || 10000);
93
30
  });
94
- }
31
+ }
32
+
33
+ function renderStreamChunk(chunk: { id: string }) {
34
+ const slotId = chunk.id;
35
+ const slotEl = document.getElementById(slotId);
36
+ const templateEl = document.getElementById(`${slotId}_content`) as HTMLTemplateElement;
37
+
38
+ if (slotEl) {
39
+ // Content AND slot are present - let's insert the content into the slot
40
+ // Ensure the content is fully done streaming in before inserting it into the slot
41
+ waitForContent(templateEl.content as unknown as HTMLElement, (el2) => {
42
+ return Array.from(el2.childNodes).find(
43
+ (node) => node.nodeType === Node.COMMENT_NODE && node.nodeValue === 'end'
44
+ );
45
+ })
46
+ .then((endComment) => {
47
+ templateEl.content.removeChild(endComment as Node);
48
+ const content = templateEl.content.cloneNode(true);
49
+ slotEl.replaceWith(content);
50
+ templateEl.parentNode?.removeChild(templateEl);
51
+ lazyLoadScripts();
52
+ })
53
+ .catch(console.error);
54
+ } else {
55
+ // Slot is NOT present - wait for it to be added to the DOM so we can insert the content into it
56
+ waitForContent(document.body, () => {
57
+ return document.getElementById(slotId);
58
+ }).then((slotEl) => {
59
+ (slotEl as HTMLElement)?.replaceWith(templateEl.content.cloneNode(true));
60
+ lazyLoadScripts();
61
+ });
62
+ }
63
+ }
64
+
65
+ // @ts-ignore
66
+ window._hscc = renderStreamChunk;
@@ -0,0 +1,200 @@
1
+ import { test, expect, describe } from 'bun:test';
2
+ import { functionToString } from './js';
3
+
4
+ describe('functionToString', () => {
5
+ describe('named functions', () => {
6
+ test('converts named function to string', () => {
7
+ function myFunction() {
8
+ return 'hello';
9
+ }
10
+
11
+ const result = functionToString(myFunction);
12
+ expect(result).toContain('function');
13
+ expect(result).toContain('myFunction');
14
+ expect(result).toContain("return 'hello'");
15
+ });
16
+
17
+ test('converts named function with parameters', () => {
18
+ function add(a: number, b: number) {
19
+ return a + b;
20
+ }
21
+
22
+ const result = functionToString(add);
23
+ expect(result).toContain('function');
24
+ expect(result).toContain('add');
25
+ expect(result).toContain('a');
26
+ expect(result).toContain('b');
27
+ });
28
+
29
+ test('converts named async function', () => {
30
+ async function fetchData() {
31
+ const response = await fetch('/api/data');
32
+ return response.json();
33
+ }
34
+
35
+ const result = functionToString(fetchData);
36
+ expect(result).toContain('async function');
37
+ expect(result).toContain('fetchData');
38
+ expect(result).toContain('await');
39
+ });
40
+ });
41
+
42
+ describe('anonymous functions', () => {
43
+ test('converts anonymous function to string', () => {
44
+ const fn = function () {
45
+ return 'anonymous';
46
+ };
47
+
48
+ const result = functionToString(fn);
49
+ expect(result).toContain('function');
50
+ expect(result).toContain("return 'anonymous'");
51
+ });
52
+
53
+ test('converts anonymous function with parameters', () => {
54
+ const fn = function (x: number, y: number) {
55
+ return x * y;
56
+ };
57
+
58
+ const result = functionToString(fn);
59
+ expect(result).toContain('function');
60
+ expect(result).toContain('x');
61
+ expect(result).toContain('y');
62
+ });
63
+
64
+ test('converts anonymous async function', () => {
65
+ const fn = async function () {
66
+ await new Promise(resolve => setTimeout(resolve, 100));
67
+ return 'done';
68
+ };
69
+
70
+ const result = functionToString(fn);
71
+ expect(result).toContain('async function');
72
+ expect(result).toContain('await');
73
+ });
74
+ });
75
+
76
+ describe('arrow functions', () => {
77
+ test('converts single-line arrow function without braces', () => {
78
+ const fn = (x: number) => x * 2;
79
+
80
+ const result = functionToString(fn);
81
+ expect(result).toContain('function(x) { return x * 2; }');
82
+ });
83
+
84
+ test('converts single-line arrow function with single parameter', () => {
85
+ const fn = (name: string) => `Hello, ${name}!`;
86
+
87
+ const result = functionToString(fn);
88
+ expect(result).toContain('function(name) { return `Hello, ${name}!`; }');
89
+ });
90
+
91
+ test('converts single-line arrow function with multiple parameters', () => {
92
+ const fn = (a: number, b: number) => a + b;
93
+
94
+ const result = functionToString(fn);
95
+ expect(result).toContain('function(a, b) { return a + b; }');
96
+ });
97
+
98
+ test('converts arrow function with braces', () => {
99
+ const fn = (x: number) => {
100
+ const doubled = x * 2;
101
+ return doubled;
102
+ };
103
+
104
+ const result = functionToString(fn);
105
+ expect(result).toContain('function');
106
+ expect(result).toContain('x');
107
+ expect(result).toContain('doubled');
108
+ });
109
+
110
+ test('converts multi-line arrow function', () => {
111
+ const fn = (items: string[]) => {
112
+ const filtered = items.filter(item => item.length > 0);
113
+ return filtered.map(item => item.toUpperCase());
114
+ };
115
+
116
+ const result = functionToString(fn);
117
+ expect(result).toContain('function');
118
+ expect(result).toContain('items');
119
+ expect(result).toContain('filtered');
120
+ });
121
+
122
+ test('converts async arrow function without braces', () => {
123
+ const fn = async (id: number) => await fetch(`/api/${id}`);
124
+
125
+ const result = functionToString(fn);
126
+ expect(result).toContain('async function');
127
+ expect(result).toContain('id');
128
+ expect(result).toContain('await');
129
+ });
130
+
131
+ test('converts async arrow function with braces', () => {
132
+ const fn = async (id: number) => {
133
+ const response = await fetch(`/api/${id}`);
134
+ return response.json();
135
+ };
136
+
137
+ const result = functionToString(fn);
138
+ expect(result).toContain('async function');
139
+ expect(result).toContain('id');
140
+ expect(result).toContain('await');
141
+ });
142
+
143
+ test('converts arrow function with no parameters', () => {
144
+ const fn = () => 'no params';
145
+
146
+ const result = functionToString(fn);
147
+ expect(result).toContain('function');
148
+ expect(result).toContain("return 'no params'");
149
+ });
150
+
151
+ test('converts arrow function with complex expression', () => {
152
+ const fn = (obj: { x: number; y: number }) => obj.x + obj.y;
153
+
154
+ const result = functionToString(fn);
155
+ expect(result).toContain('function');
156
+ expect(result).toContain('return');
157
+ expect(result).toContain('obj.x + obj.y');
158
+ });
159
+ });
160
+
161
+ describe('edge cases', () => {
162
+ test('handles function with whitespace', () => {
163
+ const fn = function () {
164
+ return 'test';
165
+ };
166
+
167
+ const result = functionToString(fn);
168
+ expect(result).toContain('function');
169
+ expect(result).toContain("return 'test'");
170
+ });
171
+
172
+ test('handles arrow function with whitespace', () => {
173
+ const fn = (x) => x * 2;
174
+
175
+ const result = functionToString(fn);
176
+ expect(result).toContain('function');
177
+ expect(result).toContain('return');
178
+ });
179
+
180
+ test('handles function with comments', () => {
181
+ const fn = function () {
182
+ // This is a comment
183
+ return 'commented';
184
+ };
185
+
186
+ const result = functionToString(fn);
187
+ expect(result).toContain('function');
188
+ expect(result).toContain("return 'commented'");
189
+ });
190
+
191
+ test('handles nested arrow functions', () => {
192
+ const fn = (arr: number[]) => arr.map(x => x * 2);
193
+
194
+ const result = functionToString(fn);
195
+ expect(result).toContain('function');
196
+ // The nested arrow function should also be converted
197
+ expect(result).toContain('x * 2');
198
+ });
199
+ });
200
+ });