@hyperspan/framework 1.0.0-alpha.13 → 1.0.0-alpha.15

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.13",
3
+ "version": "1.0.0-alpha.15",
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"
@@ -77,6 +73,7 @@
77
73
  },
78
74
  "dependencies": {
79
75
  "@hyperspan/html": "^1.0.0-alpha",
76
+ "isbot": "^5.1.32",
80
77
  "zod": "^4.1.12"
81
78
  }
82
79
  }
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
+ });
package/src/client/js.ts CHANGED
@@ -1,61 +1,94 @@
1
1
  import { html } from '@hyperspan/html';
2
+ import { assetHash as assetHashFn } from '../utils';
3
+ import { join } from 'node:path';
4
+ import type { Hyperspan as HS } from '../types';
5
+
6
+ const CWD = process.cwd();
7
+ const IS_PROD = process.env.NODE_ENV === 'production';
2
8
 
3
9
  export const JS_PUBLIC_PATH = '/_hs/js';
4
10
  export const JS_ISLAND_PUBLIC_PATH = '/_hs/js/islands';
5
11
  export const JS_IMPORT_MAP = new Map<string, string>();
12
+ const CLIENT_JS_CACHE = new Map<string, { esmName: string, exports: string, fnArgs: string, publicPath: string }>();
13
+ const EXPORT_REGEX = /export\{(.*)\}/g;
6
14
 
7
15
  /**
8
- * Render a client JS module as a script tag
16
+ * Build a client JS module and return a Hyperspan.ClientJSBuildResult object
9
17
  */
10
- export function renderClientJS<T>(module: T, loadScript?: ((module: T) => void) | string) {
11
- // @ts-ignore
12
- if (!module.__CLIENT_JS) {
13
- throw new Error(
14
- `[Hyperspan] Client JS was not loaded by Hyperspan! Ensure the filename ends with .client.ts to use this render method.`
15
- );
16
- }
18
+ export async function buildClientJS(modulePathResolved: string): Promise<HS.ClientJSBuildResult> {
19
+ const modulePath = modulePathResolved.replace('file://', '');
20
+ const assetHash = assetHashFn(modulePath);
17
21
 
18
- return html.raw(
19
- // @ts-ignore
20
- module.__CLIENT_JS.renderScriptTag({
21
- loadScript: loadScript
22
- ? typeof loadScript === 'string'
23
- ? loadScript
24
- : functionToString(loadScript)
25
- : undefined,
26
- })
27
- );
28
- }
22
+ // Cache: Avoid re-processing the same file
23
+ if (!CLIENT_JS_CACHE.has(assetHash)) {
24
+ // Build the client JS module
25
+ const result = await Bun.build({
26
+ entrypoints: [modulePath],
27
+ outdir: join(CWD, './public', JS_PUBLIC_PATH), // @TODO: Make this configurable... should be read from config file...
28
+ naming: IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined,
29
+ external: Array.from(JS_IMPORT_MAP.keys()),
30
+ minify: true,
31
+ format: 'esm',
32
+ target: 'browser',
33
+ env: 'APP_PUBLIC_*',
34
+ });
29
35
 
30
- /**
31
- * Convert a function to a string (results in loss of context!)
32
- * Handles named, async, and arrow functions
33
- */
34
- export function functionToString(fn: any) {
35
- let str = fn.toString().trim();
36
-
37
- // Ensure consistent output & handle async
38
- if (!str.includes('function ')) {
39
- if (str.includes('async ')) {
40
- str = 'async function ' + str.replace('async ', '');
41
- } else {
42
- str = 'function ' + str;
36
+ // Add output file to import map
37
+ const esmName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', '');
38
+ const publicPath = `${JS_PUBLIC_PATH}/${esmName}.js`;
39
+ JS_IMPORT_MAP.set(esmName, publicPath);
40
+
41
+ // Get the contents of the file to extract the exports
42
+ const contents = await result.outputs[0].text();
43
+ const exportLine = EXPORT_REGEX.exec(contents);
44
+
45
+ let exports = '{}';
46
+ if (exportLine) {
47
+ const exportName = exportLine[1];
48
+ exports =
49
+ '{' +
50
+ exportName
51
+ .split(',')
52
+ .map((name) => name.trim().split(' as '))
53
+ .map(([name, alias]) => `${alias === 'default' ? 'default as ' + name : alias}`)
54
+ .join(', ') +
55
+ '}';
43
56
  }
57
+ const fnArgs = exports.replace(/(\w+)\s*as\s*(\w+)/g, '$1: $2');
58
+ CLIENT_JS_CACHE.set(assetHash, { esmName, exports, fnArgs, publicPath });
44
59
  }
45
60
 
46
- const lines = str.split('\n');
47
- const firstLine = lines[0];
48
- const lastLine = lines[lines.length - 1];
61
+ const { esmName, exports, fnArgs, publicPath } = CLIENT_JS_CACHE.get(assetHash)!;
49
62
 
50
- // Arrow function conversion
51
- if (!lastLine?.includes('}')) {
52
- return str.replace('=> ', '{ return ') + '; }';
53
- }
63
+ return {
64
+ assetHash,
65
+ esmName,
66
+ publicPath,
67
+ renderScriptTag: (loadScript) => {
68
+ const t = typeof loadScript;
69
+
70
+ if (t === 'string') {
71
+ return html`
72
+ <script type="module" data-source-id="${assetHash}">import ${exports} from "${esmName}";\n(${html.raw(loadScript as string)})(${fnArgs});</script>
73
+ `;
74
+ }
75
+ if (t === 'function') {
76
+ return html`
77
+ <script type="module" data-source-id="${assetHash}">import ${exports} from "${esmName}";\n(${html.raw(functionToString(loadScript))})(${fnArgs});</script>
78
+ `;
79
+ }
54
80
 
55
- // Cleanup arrow function
56
- if (firstLine.includes('=>')) {
57
- return str.replace('=> ', '');
81
+ return html`
82
+ <script type="module" data-source-id="${assetHash}">import "${esmName}";</script>
83
+ `;
84
+ }
58
85
  }
86
+ }
59
87
 
60
- return str;
88
+ /**
89
+ * Convert a function to a string (results in loss of context!)
90
+ * Handles named, async, and arrow functions
91
+ */
92
+ export function functionToString(fn: any) {
93
+ return fn.toString().trim();
61
94
  }
package/src/layout.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { html } from '@hyperspan/html';
2
- import { JS_IMPORT_MAP } from './client/js';
2
+ import { JS_IMPORT_MAP, buildClientJS } from './client/js';
3
3
  import { CSS_PUBLIC_PATH, CSS_ROUTE_MAP } from './client/css';
4
4
  import type { Hyperspan as HS } from './types';
5
5
 
6
+ const clientStreamingJS = await buildClientJS(import.meta.resolve('./client/_hs/hyperspan-streaming.client'));
7
+
6
8
  /**
7
9
  * Output the importmap for the client so we can use ESModules on the client to load JS files on demand
8
10
  */
@@ -11,6 +13,27 @@ export function hyperspanScriptTags() {
11
13
  <script type="importmap">
12
14
  {"imports": ${Object.fromEntries(JS_IMPORT_MAP)}}
13
15
  </script>
16
+ <script id="hyperspan-streaming-script">
17
+ // [Hyperspan] Streaming - Load the client streaming JS module only when the first chunk is loaded
18
+ window._hsc = window._hsc || [];
19
+ var hscc = function(e) {
20
+ if (window._hscc !== undefined) {
21
+ window._hscc(e);
22
+ }
23
+ };
24
+ window._hsc.push = function(e) {
25
+ Array.prototype.push.call(window._hsc, e);
26
+ if (window._hsc.length === 1) {
27
+ const script = document.createElement('script');
28
+ script.src = "${clientStreamingJS.publicPath}";
29
+ document.body.appendChild(script);
30
+ script.onload = function() {
31
+ hscc(e);
32
+ };
33
+ }
34
+ hscc(e);
35
+ };
36
+ </script>
14
37
  `;
15
38
  }
16
39
 
package/src/middleware.ts CHANGED
@@ -1,4 +1,91 @@
1
+ import { formDataToJSON } from './utils';
2
+ import { z, flattenError } from 'zod/v4';
3
+
4
+ import type { ZodAny, ZodObject, ZodError } from 'zod/v4';
1
5
  import type { Hyperspan as HS } from './types';
6
+ import { HTTPResponseException } from './server';
7
+
8
+ export type TValidationType = 'json' | 'form' | 'urlencoded';
9
+
10
+ /**
11
+ * Infer the validation type from the request Content-Type header
12
+ */
13
+ function inferValidationType(headers: Headers): TValidationType {
14
+ const contentType = headers.get('content-type')?.toLowerCase() || '';
15
+
16
+ if (contentType.includes('application/json')) {
17
+ return 'json';
18
+ } else if (contentType.includes('multipart/form-data')) {
19
+ return 'form';
20
+ } else if (contentType.includes('application/x-www-form-urlencoded')) {
21
+ return 'urlencoded';
22
+ }
23
+
24
+ // Default to json if content-type is not recognized
25
+ return 'json';
26
+ }
27
+
28
+ export class ZodValidationError extends Error {
29
+ constructor(flattened: ReturnType<typeof flattenError>) {
30
+ super('Input validation error(s)');
31
+ this.name = 'ZodValidationError';
32
+
33
+ // Copy all properties from flattened error
34
+ Object.assign(this, flattened);
35
+ }
36
+ }
37
+
38
+ export function validateQuery(schema: ZodObject | ZodAny): HS.MiddlewareFunction {
39
+ return async (context: HS.Context, next: HS.NextFunction) => {
40
+ const query = formDataToJSON(context.req.query);
41
+ const validated = schema.safeParse(query);
42
+
43
+ if (!validated.success) {
44
+ const err = formatZodError(validated.error);
45
+ return context.res.error(err, { status: 400 });
46
+ }
47
+
48
+ // Store the validated query in the context variables
49
+ context.vars.query = validated.data as z.infer<typeof schema>;
50
+
51
+ return next();
52
+ }
53
+ }
54
+
55
+ export function validateBody(schema: ZodObject | ZodAny, type?: TValidationType): HS.MiddlewareFunction {
56
+ return async (context: HS.Context, next: HS.NextFunction) => {
57
+ // Infer type from Content-Type header if not provided
58
+ const validationType = type || inferValidationType(context.req.headers);
59
+
60
+ let body: unknown = {};
61
+ if (validationType === 'json') {
62
+ body = await context.req.raw.json();
63
+ } else if (validationType === 'form') {
64
+ const formData = await context.req.formData();
65
+ body = formDataToJSON(formData as FormData);
66
+ } else if (validationType === 'urlencoded') {
67
+ const urlencoded = await context.req.urlencoded();
68
+ body = formDataToJSON(urlencoded);
69
+ }
70
+ const validated = schema.safeParse(body);
71
+
72
+ if (!validated.success) {
73
+ const err = formatZodError(validated.error);
74
+ throw new HTTPResponseException(err, { status: 400 });
75
+ //return context.res.error(err, { status: 400 });
76
+ }
77
+
78
+ // Store the validated body in the context variables
79
+ context.vars.body = validated.data as z.infer<typeof schema>;
80
+
81
+ return next();
82
+ }
83
+ }
84
+
85
+ export function formatZodError(error: ZodError): ZodValidationError {
86
+ const zodError = flattenError(error);
87
+ return new ZodValidationError(zodError);
88
+ }
2
89
 
3
90
  /**
4
91
  * Type guard to check if a handler is a middleware function
@@ -55,4 +142,3 @@ export async function executeMiddleware(
55
142
  // Start execution from the first handler
56
143
  return await createNext(0)();
57
144
  }
58
-
package/src/server.ts CHANGED
@@ -1,19 +1,20 @@
1
- import { HSHtml, html, isHSHtml, renderStream, renderAsync, render } from '@hyperspan/html';
1
+ import { HSHtml, html, isHSHtml, renderStream, renderAsync, render, _typeOf } from '@hyperspan/html';
2
+ import { isbot } from 'isbot';
2
3
  import { executeMiddleware } from './middleware';
3
- import { clientJSPlugin } from './plugins';
4
4
  import { parsePath } from './utils';
5
5
  import { Cookies } from './cookies';
6
6
 
7
7
  import type { Hyperspan as HS } from './types';
8
- import { RequestOptions } from 'node:http';
9
8
 
10
9
  export const IS_PROD = process.env.NODE_ENV === 'production';
11
10
 
12
11
  export class HTTPResponseException extends Error {
12
+ public _error?: Error;
13
13
  public _response?: Response;
14
- constructor(body: string | undefined, options?: ResponseInit) {
15
- super(body);
16
- this._response = new Response(body, options);
14
+ constructor(body: string | Error | undefined, options?: ResponseInit) {
15
+ super(body instanceof Error ? body.message : body);
16
+ this._error = body instanceof Error ? body : undefined;
17
+ this._response = new Response(body instanceof Error ? body.message : body, options);
17
18
  }
18
19
  }
19
20
 
@@ -21,11 +22,22 @@ export class HTTPResponseException extends Error {
21
22
  * Ensures a valid config object is returned, even with an empty object or partial object passed in
22
23
  */
23
24
  export function createConfig(config: Partial<HS.Config> = {}): HS.Config {
25
+ const defaultConfig: HS.Config = {
26
+ appDir: './app',
27
+ publicDir: './public',
28
+ plugins: [],
29
+ responseOptions: {
30
+ // Disable streaming for bots by default
31
+ disableStreaming: (c) => isbot(c.req.raw.headers.get('user-agent') ?? ''),
32
+ },
33
+ };
24
34
  return {
35
+ ...defaultConfig,
25
36
  ...config,
26
- appDir: config.appDir ?? './app',
27
- publicDir: config.publicDir ?? './public',
28
- plugins: [clientJSPlugin(), ...(config.plugins ?? [])],
37
+ responseOptions: {
38
+ ...defaultConfig.responseOptions,
39
+ ...config.responseOptions,
40
+ },
29
41
  };
30
42
  }
31
43
 
@@ -104,6 +116,7 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
104
116
  */
105
117
  export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
106
118
  const _handlers: Record<string, HS.RouteHandler> = {};
119
+ let _errorHandler: HS.ErrorHandler | undefined = undefined;
107
120
  let _middleware: Record<string, Array<HS.MiddlewareFunction>> = { '*': [] };
108
121
 
109
122
  const api: HS.Route = {
@@ -142,14 +155,6 @@ export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
142
155
  _middleware['PUT'] = handlerOptions?.middleware || [];
143
156
  return api;
144
157
  },
145
- /**
146
- * Add a DELETE route handler (typically to delete existing data)
147
- */
148
- delete(handler: HS.RouteHandler, handlerOptions?: HS.RouteHandlerOptions) {
149
- _handlers['DELETE'] = handler;
150
- _middleware['DELETE'] = handlerOptions?.middleware || [];
151
- return api;
152
- },
153
158
  /**
154
159
  * Add a PATCH route handler (typically to update existing data)
155
160
  */
@@ -158,6 +163,14 @@ export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
158
163
  _middleware['PATCH'] = handlerOptions?.middleware || [];
159
164
  return api;
160
165
  },
166
+ /**
167
+ * Add a DELETE route handler (typically to delete existing data)
168
+ */
169
+ delete(handler: HS.RouteHandler, handlerOptions?: HS.RouteHandlerOptions) {
170
+ _handlers['DELETE'] = handler;
171
+ _middleware['DELETE'] = handlerOptions?.middleware || [];
172
+ return api;
173
+ },
161
174
  /**
162
175
  * Add a OPTIONS route handler (typically to handle CORS preflight requests)
163
176
  */
@@ -166,8 +179,11 @@ export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
166
179
  _middleware['OPTIONS'] = handlerOptions?.middleware || [];
167
180
  return api;
168
181
  },
169
- errorHandler(handler: HS.RouteHandler) {
170
- _handlers['_ERROR'] = handler;
182
+ /**
183
+ * Set a custom error handler for this route to fall back to if the route handler throws an error
184
+ */
185
+ errorHandler(handler: HS.ErrorHandler) {
186
+ _errorHandler = handler;
171
187
  return api;
172
188
  },
173
189
  /**
@@ -225,7 +241,14 @@ export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
225
241
  }
226
242
 
227
243
  if (isHTMLContent(routeContent)) {
228
- return returnHTMLResponse(context, () => routeContent);
244
+ // Merge server and route-specific response options
245
+ const responseOptions = { ...(api._serverConfig?.responseOptions ?? {}), ...(api._config?.responseOptions ?? {}) };
246
+ return returnHTMLResponse(context, () => routeContent, responseOptions);
247
+ }
248
+
249
+ const contentType = _typeOf(routeContent);
250
+ if (contentType === 'generator') {
251
+ return new StreamResponse(routeContent as AsyncGenerator);
229
252
  }
230
253
 
231
254
  return routeContent;
@@ -236,8 +259,9 @@ export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
236
259
  try {
237
260
  return await executeMiddleware(context, [...globalMiddleware, ...methodMiddleware, methodHandler]);
238
261
  } catch (e) {
239
- if (_handlers['_ERROR']) {
240
- return await (_handlers['_ERROR'](context) as Promise<Response>);
262
+ if (_errorHandler !== undefined) {
263
+ const responseOptions = { ...(api._serverConfig?.responseOptions ?? {}), ...(api._config?.responseOptions ?? {}) };
264
+ return returnHTMLResponse(context, () => (_errorHandler as HS.ErrorHandler)(context, e as Error), responseOptions);
241
265
  }
242
266
  throw e;
243
267
  }
@@ -325,7 +349,7 @@ function isHTMLContent(response: unknown): response is Response {
325
349
  export async function returnHTMLResponse(
326
350
  context: HS.Context,
327
351
  handlerFn: () => unknown,
328
- responseOptions?: { status?: number; headers?: Record<string, string> }
352
+ responseOptions?: { status?: number; headers?: Record<string, string>; disableStreaming?: (context: HS.Context) => boolean }
329
353
  ): Promise<Response> {
330
354
  try {
331
355
  const routeContent = await handlerFn();
@@ -337,14 +361,22 @@ export async function returnHTMLResponse(
337
361
 
338
362
  // Render HSHtml if returned from route handler
339
363
  if (isHSHtml(routeContent)) {
340
- // @TODO: Move this to config or something...
341
- const streamOpt = context.req.query.get('__nostream');
342
- const streamingEnabled = (streamOpt !== undefined ? streamOpt : true);
364
+ const disableStreaming = responseOptions?.disableStreaming?.(context) ?? false;
343
365
 
344
366
  // Stream only if enabled and there is async content to stream
345
- if (streamingEnabled && (routeContent as HSHtml).asyncContent?.length > 0) {
367
+ if (!disableStreaming && (routeContent as HSHtml).asyncContent?.length > 0) {
346
368
  return new StreamResponse(
347
- renderStream(routeContent as HSHtml),
369
+ renderStream(routeContent as HSHtml, {
370
+ renderChunk: (chunk) => {
371
+ return html`
372
+ <template id="${chunk.id}_content">${html.raw(chunk.content)}<!--end--></template>
373
+ <script>
374
+ window._hsc = window._hsc || [];
375
+ window._hsc.push({id: "${chunk.id}" });
376
+ </script>
377
+ `;
378
+ }
379
+ }),
348
380
  responseOptions
349
381
  ) as Response;
350
382
  } else {
package/src/types.ts CHANGED
@@ -27,6 +27,9 @@ export namespace Hyperspan {
27
27
  // For customizing the routes and adding your own...
28
28
  beforeRoutesAdded?: (server: Hyperspan.Server) => void;
29
29
  afterRoutesAdded?: (server: Hyperspan.Server) => void;
30
+ responseOptions?: {
31
+ disableStreaming?: (context: Hyperspan.Context) => boolean;
32
+ };
30
33
  };
31
34
 
32
35
  export type CookieOptions = {
@@ -92,6 +95,9 @@ export namespace Hyperspan {
92
95
  path: string;
93
96
  params: Record<string, string | undefined>;
94
97
  cssImports: string[];
98
+ responseOptions?: {
99
+ disableStreaming?: (context: Hyperspan.Context) => boolean;
100
+ };
95
101
  };
96
102
  export type RouteHandler = (context: Hyperspan.Context) => unknown;
97
103
  export type RouteHandlerOptions = {
@@ -113,6 +119,11 @@ export namespace Hyperspan {
113
119
  */
114
120
  export type NextFunction = () => Promise<Response>;
115
121
 
122
+ /**
123
+ * Error handler function signature
124
+ */
125
+ export type ErrorHandler = (context: Hyperspan.Context, error: Error) => unknown | undefined;
126
+
116
127
  /**
117
128
  * Middleware function signature
118
129
  * Accepts context and next function, returns a Response
@@ -125,6 +136,7 @@ export namespace Hyperspan {
125
136
  export interface Route {
126
137
  _kind: 'hsRoute';
127
138
  _config: Partial<Hyperspan.RouteConfig>;
139
+ _serverConfig?: Hyperspan.Config;
128
140
  _path(): string;
129
141
  _methods(): string[];
130
142
  get: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
@@ -133,7 +145,7 @@ export namespace Hyperspan {
133
145
  patch: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
134
146
  delete: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
135
147
  options: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
136
- errorHandler: (handler: Hyperspan.RouteHandler) => Hyperspan.Route;
148
+ errorHandler: (handler: Hyperspan.ErrorHandler) => Hyperspan.Route;
137
149
  middleware: (middleware: Array<Hyperspan.MiddlewareFunction>) => Hyperspan.Route;
138
150
  fetch: (request: Request) => Promise<Response>;
139
151
  };
@@ -148,7 +160,7 @@ export namespace Hyperspan {
148
160
  ) => ActionResponse;
149
161
  export interface Action<T extends z.ZodTypeAny> {
150
162
  _kind: 'hsAction';
151
- _config: Hyperspan.RouteConfig;
163
+ _config: Partial<Hyperspan.RouteConfig>;
152
164
  _path(): string;
153
165
  _form: null | ActionFormHandler<T>;
154
166
  form(form: ActionFormHandler<T>): Action<T>;
@@ -158,4 +170,19 @@ export namespace Hyperspan {
158
170
  middleware: (middleware: Array<Hyperspan.MiddlewareFunction>) => Action<T>;
159
171
  fetch: (request: Request) => Promise<Response>;
160
172
  }
173
+
174
+ /**
175
+ * Client JS Module = ESM Module + Public Path + Render Script Tag
176
+ */
177
+ export type ClientJSBuildResult = {
178
+ assetHash: string; // Asset hash of the module path
179
+ esmName: string; // Filename of the built JavaScript file without the extension
180
+ publicPath: string; // Full public path of the built JavaScript file
181
+ /**
182
+ * Render a <script type="module"> tag for the JS module
183
+ * @param loadScript - A function that loads the module or a string of code to load the module
184
+ * @returns HSHtml Template with the <script type="module"> tag
185
+ */
186
+ renderScriptTag: (loadScript?: ((module: unknown) => HSHtml | string) | string) => HSHtml;
187
+ }
161
188
  }
package/src/plugins.ts DELETED
@@ -1,94 +0,0 @@
1
- import type { Hyperspan as HS } from './types';
2
- import { JS_PUBLIC_PATH, JS_IMPORT_MAP } from './client/js';
3
- import { assetHash } from './utils';
4
- import { IS_PROD } from './server';
5
- import { join } from 'node:path';
6
-
7
- export const CSS_PUBLIC_PATH = '/_hs/css';
8
- const CLIENT_JS_CACHE = new Map<string, string>();
9
- const EXPORT_REGEX = /export\{(.*)\}/g;
10
-
11
- /**
12
- * Hyperspan Client JS Plugin
13
- */
14
- export function clientJSPlugin(): HS.Plugin {
15
- return async (config: HS.Config) => {
16
- // Define a Bun plugin to handle .client.ts files
17
- await Bun.plugin({
18
- name: 'Hyperspan Client JS Loader',
19
- async setup(build) {
20
- // when a .client.ts file is imported...
21
- build.onLoad({ filter: /\.client\.ts$/ }, async (args) => {
22
- const jsId = assetHash(args.path);
23
-
24
- // Cache: Avoid re-processing the same file
25
- if (IS_PROD && CLIENT_JS_CACHE.has(jsId)) {
26
- return {
27
- contents: CLIENT_JS_CACHE.get(jsId) || '',
28
- loader: 'js',
29
- };
30
- }
31
-
32
- // We need to build the file to ensure we can ship it to the client with dependencies
33
- // Ironic, right? Calling Bun.build() inside of a plugin that runs on Bun.build()?
34
- const result = await Bun.build({
35
- entrypoints: [args.path],
36
- outdir: join(config.publicDir, JS_PUBLIC_PATH),
37
- naming: IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined,
38
- external: Array.from(JS_IMPORT_MAP.keys()),
39
- minify: IS_PROD,
40
- format: 'esm',
41
- target: 'browser',
42
- env: 'APP_PUBLIC_*',
43
- });
44
-
45
- // Add output file to import map
46
- const esmName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', '');
47
- JS_IMPORT_MAP.set(esmName, `${JS_PUBLIC_PATH}/${esmName}.js`);
48
-
49
- // Get the contents of the file to extract the exports
50
- const contents = await result.outputs[0].text();
51
- const exportLine = EXPORT_REGEX.exec(contents);
52
-
53
- let exports = '{}';
54
- if (exportLine) {
55
- const exportName = exportLine[1];
56
- exports =
57
- '{' +
58
- exportName
59
- .split(',')
60
- .map((name) => name.trim().split(' as '))
61
- .map(([name, alias]) => `${alias === 'default' ? 'default as ' + name : alias}`)
62
- .join(', ') +
63
- '}';
64
- }
65
- const fnArgs = exports.replace(/(\w+)\s*as\s*(\w+)/g, '$1: $2');
66
-
67
- // Export a special object that can be used to render the client JS as a script tag
68
- const moduleCode = `// hyperspan:processed
69
- import { functionToString } from '@hyperspan/framework/client/js';
70
-
71
- // hyperspan:client-js-plugin
72
- export const __CLIENT_JS = {
73
- id: "${jsId}",
74
- esmName: "${esmName}",
75
- sourceFile: "${args.path}",
76
- outputFile: "${result.outputs[0].path}",
77
- renderScriptTag: ({ loadScript }) => {
78
- const fn = loadScript ? (typeof loadScript === 'string' ? loadScript : \`const fn = \${functionToString(loadScript)}; fn(${fnArgs});\`) : '';
79
- return \`<script type="module" data-source-id="${jsId}">import ${exports} from "${esmName}";\n\${fn}</script>\`;
80
- },
81
- }
82
- `;
83
-
84
- CLIENT_JS_CACHE.set(jsId, moduleCode);
85
-
86
- return {
87
- contents: moduleCode,
88
- loader: 'js',
89
- };
90
- });
91
- },
92
- });
93
- };
94
- }