@hyperspan/framework 1.0.0-alpha.9 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspan/framework",
3
- "version": "1.0.0-alpha.9",
3
+ "version": "1.0.1",
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
  }
@@ -126,7 +126,7 @@ describe('createAction', () => {
126
126
  const formData = new FormData();
127
127
  formData.append('email', 'not-an-email');
128
128
 
129
- const postRequest = new Request(`http://localhost:3000${action._route}`, {
129
+ const postRequest = new Request(`http://localhost:3000${action._path()}`, {
130
130
  method: 'POST',
131
131
  body: formData,
132
132
  });
@@ -143,5 +143,56 @@ describe('createAction', () => {
143
143
  // Should NOT contain the success message from post handler
144
144
  expect(responseText).not.toContain('Hello,');
145
145
  });
146
+
147
+ test('uses custom error handler when provided', async () => {
148
+ const schema = z.object({
149
+ name: z.string().min(1, 'Name is required'),
150
+ email: z.email('Invalid email address'),
151
+ });
152
+
153
+ const action = createAction({
154
+ name: 'test',
155
+ schema,
156
+ }).form((c, { data, error }) => {
157
+ return html`
158
+ <form>
159
+ <input type="text" name="name" value="${data?.name || ''}" />
160
+ ${error ? html`<div class="error">Validation failed</div>` : ''}
161
+ <input type="email" name="email" value="${data?.email || ''}" />
162
+ <button type="submit">Submit</button>
163
+ </form>
164
+ `;
165
+ }).post(async (c, { data }) => {
166
+ return c.res.html(`
167
+ <p>Hello, ${data?.name}!</p>
168
+ <p>Your email is ${data?.email}.</p>
169
+ `);
170
+ }).errorHandler(async (c, { data, error }) => {
171
+ return c.res.html(`
172
+ <p>Caught error in custom error handler: ${error?.message}</p>
173
+ <p>Data: ${JSON.stringify(data)}</p>
174
+ `);
175
+ });
176
+
177
+ // Test fetch method with invalid data (missing name, invalid email)
178
+ const formData = new FormData();
179
+ formData.append('email', 'not-an-email');
180
+
181
+ const postRequest = new Request(`http://localhost:3000${action._path()}`, {
182
+ method: 'POST',
183
+ body: formData,
184
+ });
185
+
186
+ const response = await action.fetch(postRequest);
187
+ expect(response).toBeInstanceOf(Response);
188
+ expect(response.status).toBe(400);
189
+
190
+ const responseText = await response.text();
191
+ // Should render the custom error handler
192
+ expect(responseText).toContain('Caught error in custom error handler: Input validation error(s)');
193
+ expect(responseText).toContain('Data: {"email":"not-an-email"}');
194
+ // Should NOT contain the success message from post handler
195
+ expect(responseText).not.toContain('Hello,');
196
+ });
146
197
  });
147
198
 
package/src/actions.ts CHANGED
@@ -1,10 +1,12 @@
1
- import { html, HSHtml } from '@hyperspan/html';
2
- import { createRoute, returnHTMLResponse } from './server';
1
+ import { html } from '@hyperspan/html';
2
+ import { createRoute, HTTPResponseException, 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, ZodValidationError } 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
 
@@ -29,48 +31,41 @@ export function createAction<T extends z.ZodTypeAny>(params: { name: string; sch
29
31
  const route = createRoute({ path, name })
30
32
  .get((c: HS.Context) => api.render(c))
31
33
  .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
- }
34
+ if (!_handler) {
35
+ throw new Error('Action POST handler not set! Every action must have a POST handler.');
36
+ }
47
37
 
48
- const response = await _handler(c, { data });
38
+ const response = await _handler(c, { data: c.vars.body });
49
39
 
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
- }
40
+ if (response instanceof Response) {
41
+ // Replace redirects with special header because fetch() automatically follows redirects
42
+ // and we want to redirect the user to the actual full page instead
43
+ if ([301, 302, 307, 308].includes(response.status)) {
44
+ response.headers.set('X-Redirect-Location', response.headers.get('Location') || '/');
45
+ response.headers.delete('Location');
57
46
  }
58
-
59
- return response;
60
- } catch (e) {
61
- error = e as Error | z.ZodError;
62
47
  }
63
48
 
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
- }
49
+ return response;
50
+ }, { middleware: schema ? [validateBody(schema)] : [] })
51
+ /**
52
+ * Custom error handler for the action since validateBody() throws a HTTPResponseException
53
+ */
54
+ .errorHandler(async (c: HS.Context, err: HTTPResponseException) => {
55
+ const data = c.vars.body as Partial<z.infer<T>>;
56
+ const error = err._error as ZodValidationError;
57
+
58
+ // Set the status to 400 by default
59
+ c.res.status = 400;
70
60
 
71
- return await returnHTMLResponse(c, () => api.render(c, { data, error }), { status: 400 });
61
+ return await returnHTMLResponse(c, () => {
62
+ return _errorHandler ? _errorHandler(c, { data, error }) : api.render(c, { data, error });
63
+ }, { status: 400 });
72
64
  });
73
65
 
66
+ // Set the name of the action for the route
67
+ route._config.name = name;
68
+
74
69
  const api: HS.Action<T> = {
75
70
  _kind: 'hsAction',
76
71
  _config: route._config,
@@ -101,7 +96,7 @@ export function createAction<T extends z.ZodTypeAny>(params: { name: string; sch
101
96
  */
102
97
  render(c: HS.Context, props?: HS.ActionProps<T>) {
103
98
  const formContent = api._form ? api._form(c, props || {}) : null;
104
- return formContent ? html`<hs-action url="${this._path()}">${formContent}</hs-action>${renderClientJS(actionsClient)}` : null;
99
+ return formContent ? html`<hs-action url="${this._path()}">${formContent}</hs-action>${actionsClientJS.renderScriptTag()}` : null;
105
100
  },
106
101
  errorHandler(handler) {
107
102
  _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;
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/cookies.ts CHANGED
@@ -42,7 +42,7 @@ export class Cookies implements HS.Cookies {
42
42
  if (this._encrypt) {
43
43
  value = this._encrypt(value);
44
44
  }
45
- this._responseHeaders.set('Set-Cookie', serialize(name, value, options));
45
+ this._responseHeaders.append('Set-Cookie', serialize(name, value, options));
46
46
  }
47
47
 
48
48
  delete(name: string) {
package/src/index.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { createConfig, createContext, createRoute, createServer, getRunnableRoute, StreamResponse, IS_PROD, HTTPException } from './server';
1
+ export { createConfig, createContext, createRoute, createServer, getRunnableRoute, StreamResponse, IS_PROD, HTTPResponseException } from './server';
2
2
  export type { Hyperspan } from './types';
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,90 @@
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
+ // Store the validated query in the context variables
44
+ context.vars.query = validated.data as z.infer<typeof schema>;
45
+
46
+ if (!validated.success) {
47
+ const err = formatZodError(validated.error);
48
+ return context.res.error(err, { status: 400 });
49
+ }
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
+
71
+ context.vars.body = body as z.infer<typeof schema>;
72
+ const validated = schema.safeParse(body);
73
+
74
+ if (!validated.success) {
75
+ const err = formatZodError(validated.error);
76
+ throw new HTTPResponseException(err, { status: 400 });
77
+ //return context.res.error(err, { status: 400 });
78
+ }
79
+
80
+ return next();
81
+ }
82
+ }
83
+
84
+ export function formatZodError(error: ZodError): ZodValidationError {
85
+ const zodError = flattenError(error);
86
+ return new ZodValidationError(zodError);
87
+ }
2
88
 
3
89
  /**
4
90
  * Type guard to check if a handler is a middleware function
@@ -55,4 +141,3 @@ export async function executeMiddleware(
55
141
  // Start execution from the first handler
56
142
  return await createNext(0)();
57
143
  }
58
-
@@ -113,7 +113,7 @@ test('createContext() can get and set cookies', () => {
113
113
  expect(setCookieHeader).toBeTruthy();
114
114
  expect(setCookieHeader).toContain('newCookie=newValue');
115
115
 
116
- // Test setting a cookie with options (this will overwrite the previous Set-Cookie header)
116
+ // Test setting a cookie with options (this should NOT overwrite the previous Set-Cookie header)
117
117
  context.res.cookies.set('secureCookie', 'secureValue', {
118
118
  httpOnly: true,
119
119
  secure: true,
@@ -125,13 +125,7 @@ test('createContext() can get and set cookies', () => {
125
125
  setCookieHeader = context.res.headers.get('Set-Cookie');
126
126
  expect(setCookieHeader).toBeTruthy();
127
127
  expect(setCookieHeader).toContain('secureCookie=secureValue');
128
- expect(setCookieHeader).toContain('HttpOnly');
129
- expect(setCookieHeader).toContain('Secure');
130
- expect(setCookieHeader).toContain('SameSite=Strict');
131
- expect(setCookieHeader).toContain('Max-Age=3600');
132
-
133
- // Verify the previous cookie was overwritten
134
- expect(setCookieHeader).not.toContain('newCookie=newValue');
128
+ expect(setCookieHeader).toContain('newCookie=newValue');
135
129
 
136
130
  // Test deleting a cookie
137
131
  context.res.cookies.delete('sessionId');
package/src/server.ts CHANGED
@@ -1,6 +1,6 @@
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
 
@@ -8,9 +8,13 @@ import type { Hyperspan as HS } from './types';
8
8
 
9
9
  export const IS_PROD = process.env.NODE_ENV === 'production';
10
10
 
11
- export class HTTPException extends Error {
12
- constructor(public status: number, message?: string) {
13
- super(message);
11
+ export class HTTPResponseException extends Error {
12
+ public _error?: Error;
13
+ public _response?: Response;
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);
14
18
  }
15
19
  }
16
20
 
@@ -18,11 +22,22 @@ export class HTTPException extends Error {
18
22
  * Ensures a valid config object is returned, even with an empty object or partial object passed in
19
23
  */
20
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
+ };
21
34
  return {
35
+ ...defaultConfig,
22
36
  ...config,
23
- appDir: config.appDir ?? './app',
24
- publicDir: config.publicDir ?? './public',
25
- plugins: [clientJSPlugin(), ...(config.plugins ?? [])],
37
+ responseOptions: {
38
+ ...defaultConfig.responseOptions,
39
+ ...config.responseOptions,
40
+ },
26
41
  };
27
42
  }
28
43
 
@@ -36,7 +51,18 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
36
51
  const headers = new Headers(req.headers);
37
52
  const path = route?._path() || '/';
38
53
  // @ts-ignore - Bun will put 'params' on the Request object even though it's not standardized
39
- const params: HS.RouteParamsParser<path> = req?.params || {};
54
+ const params: HS.RouteParamsParser<path> & Record<string, string | undefined> = Object.assign({}, req?.params || {}, route?._config.params || {});
55
+
56
+ // Replace catch-all param with the value from the URL path
57
+ const catchAllParam = Object.keys(params).find(key => key.startsWith('...'));
58
+ if (catchAllParam && path.includes('/*')) {
59
+ const catchAllValue = url.pathname.split(path.replace('/*', '/')).pop();
60
+ params[catchAllParam.replace('...', '')] = catchAllValue;
61
+ delete params[catchAllParam];
62
+ }
63
+
64
+ // Status override for the response. Will use if set. (e.g. c.res.status = 400)
65
+ let status: number | undefined = undefined;
40
66
 
41
67
  const merge = (response: Response) => {
42
68
  // Convert headers to plain objects and merge (response headers override context headers)
@@ -46,14 +72,15 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
46
72
  };
47
73
 
48
74
  return new Response(response.body, {
49
- status: response.status,
75
+ status: context.res.status ?? response.status,
50
76
  headers: mergedHeaders,
51
77
  });
52
78
  };
53
79
 
54
- return {
80
+ const context: HS.Context = {
55
81
  vars: {},
56
82
  route: {
83
+ name: route?._config.name || undefined,
57
84
  path,
58
85
  params: params,
59
86
  cssImports: route ? route._config.cssImports ?? [] : [],
@@ -65,24 +92,26 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
65
92
  headers,
66
93
  query,
67
94
  cookies: new Cookies(req),
68
- async text() { return req.text() },
69
- async json<T = unknown>() { return await req.json() as T },
70
- async formData<T = unknown>() { return await req.formData() as T },
71
- async urlencoded() { return new URLSearchParams(await req.text()) },
95
+ async text() { return req.clone().text() },
96
+ async json<T = unknown>() { return await req.clone().json() as T },
97
+ async formData<T = unknown>() { return await req.clone().formData() as T },
98
+ async urlencoded() { return new URLSearchParams(await req.clone().text()) },
72
99
  },
73
100
  res: {
74
101
  cookies: new Cookies(req, headers),
75
102
  headers,
76
- raw: new Response(),
77
- html: (html: string, options?: { status?: number; headers?: Headers | Record<string, string> }) => merge(new Response(html, { ...options, headers: { 'Content-Type': 'text/html; charset=UTF-8', ...options?.headers } })),
78
- json: (json: any, options?: { status?: number; headers?: Headers | Record<string, string> }) => merge(new Response(JSON.stringify(json), { ...options, headers: { 'Content-Type': 'application/json', ...options?.headers } })),
79
- text: (text: string, options?: { status?: number; headers?: Headers | Record<string, string> }) => merge(new Response(text, { ...options, headers: { 'Content-Type': 'text/plain; charset=UTF-8', ...options?.headers } })),
80
- redirect: (url: string, options?: { status?: number; headers?: Headers | Record<string, string> }) => merge(new Response(null, { status: 302, headers: { Location: url, ...options?.headers } })),
81
- error: (error: Error, options?: { status?: number; headers?: Headers | Record<string, string> }) => merge(new Response(error.message, { status: 500, ...options })),
82
- notFound: (options?: { status?: number; headers?: Headers | Record<string, string> }) => merge(new Response('Not Found', { status: 404, ...options })),
103
+ status,
104
+ html: (html: string, options?: ResponseInit) => merge(new Response(html, { ...options, headers: { 'Content-Type': 'text/html; charset=UTF-8', ...options?.headers } })),
105
+ json: (json: any, options?: ResponseInit) => merge(new Response(JSON.stringify(json), { ...options, headers: { 'Content-Type': 'application/json', ...options?.headers } })),
106
+ text: (text: string, options?: ResponseInit) => merge(new Response(text, { ...options, headers: { 'Content-Type': 'text/plain; charset=UTF-8', ...options?.headers } })),
107
+ redirect: (url: string, options?: ResponseInit) => merge(new Response(null, { status: 302, headers: { Location: url, ...options?.headers } })),
108
+ error: (error: Error, options?: ResponseInit) => merge(new Response(error.message, { status: 500, ...options })),
109
+ notFound: (options?: ResponseInit) => merge(new Response('Not Found', { status: 404, ...options })),
83
110
  merge,
84
111
  },
85
112
  };
113
+
114
+ return context;
86
115
  }
87
116
 
88
117
 
@@ -90,8 +119,9 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
90
119
  * Define a route that can handle a direct HTTP request.
91
120
  * Route handlers should return a HSHtml or Response object
92
121
  */
93
- export function createRoute(config: HS.RouteConfig = {}): HS.Route {
122
+ export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
94
123
  const _handlers: Record<string, HS.RouteHandler> = {};
124
+ let _errorHandler: HS.ErrorHandler | undefined = undefined;
95
125
  let _middleware: Record<string, Array<HS.MiddlewareFunction>> = { '*': [] };
96
126
 
97
127
  const api: HS.Route = {
@@ -130,14 +160,6 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
130
160
  _middleware['PUT'] = handlerOptions?.middleware || [];
131
161
  return api;
132
162
  },
133
- /**
134
- * Add a DELETE route handler (typically to delete existing data)
135
- */
136
- delete(handler: HS.RouteHandler, handlerOptions?: HS.RouteHandlerOptions) {
137
- _handlers['DELETE'] = handler;
138
- _middleware['DELETE'] = handlerOptions?.middleware || [];
139
- return api;
140
- },
141
163
  /**
142
164
  * Add a PATCH route handler (typically to update existing data)
143
165
  */
@@ -146,6 +168,14 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
146
168
  _middleware['PATCH'] = handlerOptions?.middleware || [];
147
169
  return api;
148
170
  },
171
+ /**
172
+ * Add a DELETE route handler (typically to delete existing data)
173
+ */
174
+ delete(handler: HS.RouteHandler, handlerOptions?: HS.RouteHandlerOptions) {
175
+ _handlers['DELETE'] = handler;
176
+ _middleware['DELETE'] = handlerOptions?.middleware || [];
177
+ return api;
178
+ },
149
179
  /**
150
180
  * Add a OPTIONS route handler (typically to handle CORS preflight requests)
151
181
  */
@@ -154,8 +184,11 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
154
184
  _middleware['OPTIONS'] = handlerOptions?.middleware || [];
155
185
  return api;
156
186
  },
157
- errorHandler(handler: HS.RouteHandler) {
158
- _handlers['_ERROR'] = handler;
187
+ /**
188
+ * Set a custom error handler for this route to fall back to if the route handler throws an error
189
+ */
190
+ errorHandler(handler: HS.ErrorHandler) {
191
+ _errorHandler = handler;
159
192
  return api;
160
193
  },
161
194
  /**
@@ -213,7 +246,14 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
213
246
  }
214
247
 
215
248
  if (isHTMLContent(routeContent)) {
216
- return returnHTMLResponse(context, () => routeContent);
249
+ // Merge server and route-specific response options
250
+ const responseOptions = { ...(api._serverConfig?.responseOptions ?? {}), ...(api._config?.responseOptions ?? {}) };
251
+ return returnHTMLResponse(context, () => routeContent, responseOptions);
252
+ }
253
+
254
+ const contentType = _typeOf(routeContent);
255
+ if (contentType === 'generator') {
256
+ return new StreamResponse(routeContent as AsyncGenerator);
217
257
  }
218
258
 
219
259
  return routeContent;
@@ -224,8 +264,9 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
224
264
  try {
225
265
  return await executeMiddleware(context, [...globalMiddleware, ...methodMiddleware, methodHandler]);
226
266
  } catch (e) {
227
- if (_handlers['_ERROR']) {
228
- return await (_handlers['_ERROR'](context) as Promise<Response>);
267
+ if (_errorHandler !== undefined) {
268
+ const responseOptions = { ...(api._serverConfig?.responseOptions ?? {}), ...(api._config?.responseOptions ?? {}) };
269
+ return returnHTMLResponse(context, () => (_errorHandler as HS.ErrorHandler)(context, e as Error), responseOptions);
229
270
  }
230
271
  throw e;
231
272
  }
@@ -313,7 +354,7 @@ function isHTMLContent(response: unknown): response is Response {
313
354
  export async function returnHTMLResponse(
314
355
  context: HS.Context,
315
356
  handlerFn: () => unknown,
316
- responseOptions?: { status?: number; headers?: Record<string, string> }
357
+ responseOptions?: { status?: number; headers?: Record<string, string>; disableStreaming?: (context: HS.Context) => boolean }
317
358
  ): Promise<Response> {
318
359
  try {
319
360
  const routeContent = await handlerFn();
@@ -325,14 +366,22 @@ export async function returnHTMLResponse(
325
366
 
326
367
  // Render HSHtml if returned from route handler
327
368
  if (isHSHtml(routeContent)) {
328
- // @TODO: Move this to config or something...
329
- const streamOpt = context.req.query.get('__nostream');
330
- const streamingEnabled = (streamOpt !== undefined ? streamOpt : true);
369
+ const disableStreaming = responseOptions?.disableStreaming?.(context) ?? false;
331
370
 
332
371
  // Stream only if enabled and there is async content to stream
333
- if (streamingEnabled && (routeContent as HSHtml).asyncContent?.length > 0) {
372
+ if (!disableStreaming && (routeContent as HSHtml).asyncContent?.length > 0) {
334
373
  return new StreamResponse(
335
- renderStream(routeContent as HSHtml),
374
+ renderStream(routeContent as HSHtml, {
375
+ renderChunk: (chunk) => {
376
+ return html`
377
+ <template id="${chunk.id}_content">${html.raw(chunk.content)}<!--end--></template>
378
+ <script>
379
+ window._hsc = window._hsc || [];
380
+ window._hsc.push({id: "${chunk.id}" });
381
+ </script>
382
+ `;
383
+ }
384
+ }),
336
385
  responseOptions
337
386
  ) as Response;
338
387
  } else {
@@ -398,8 +447,8 @@ async function showErrorReponse(
398
447
  const message = err.message || 'Internal Server Error';
399
448
 
400
449
  // Send correct status code if HTTPException
401
- if (err instanceof HTTPException) {
402
- status = err.status;
450
+ if (err instanceof HTTPResponseException) {
451
+ status = err._response?.status ?? 500;
403
452
  }
404
453
 
405
454
  const stack = !IS_PROD && err.stack ? err.stack.split('\n').slice(1).join('\n') : '';
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 = {
@@ -49,37 +52,37 @@ export namespace Hyperspan {
49
52
  delete: (name: string) => void;
50
53
  }
51
54
 
55
+ export type HSRequest = {
56
+ url: URL;
57
+ raw: Request;
58
+ method: string; // Always uppercase
59
+ headers: Headers; // Case-insensitive
60
+ query: URLSearchParams;
61
+ cookies: Hyperspan.Cookies;
62
+ text: () => Promise<string>;
63
+ json<T = unknown>(): Promise<T>;
64
+ formData(): Promise<FormData>;
65
+ urlencoded(): Promise<URLSearchParams>;
66
+ };
67
+
68
+ export type HSResponse = {
69
+ cookies: Hyperspan.Cookies;
70
+ headers: Headers; // Headers to merge with final outgoing response
71
+ status: number | undefined;
72
+ html: (html: string, options?: ResponseInit) => Response
73
+ json: (json: any, options?: ResponseInit) => Response;
74
+ text: (text: string, options?: ResponseInit) => Response;
75
+ redirect: (url: string, options?: ResponseInit) => Response;
76
+ error: (error: Error, options?: ResponseInit) => Response;
77
+ notFound: (options?: ResponseInit) => Response;
78
+ merge: (response: Response) => Response;
79
+ };
80
+
52
81
  export interface Context {
53
82
  vars: Record<string, any>;
54
- route: {
55
- path: string;
56
- params: Record<string, string>;
57
- cssImports?: string[];
58
- }
59
- req: {
60
- url: URL;
61
- raw: Request;
62
- method: string; // Always uppercase
63
- headers: Headers; // Case-insensitive
64
- query: URLSearchParams;
65
- cookies: Hyperspan.Cookies;
66
- text: () => Promise<string>;
67
- json<T = unknown>(): Promise<T>;
68
- formData(): Promise<FormData>;
69
- urlencoded(): Promise<URLSearchParams>;
70
- };
71
- res: {
72
- cookies: Hyperspan.Cookies;
73
- headers: Headers; // Headers to merge with final outgoing response
74
- html: (html: string, options?: { status?: number; headers?: Record<string, string> }) => Response
75
- json: (json: any, options?: { status?: number; headers?: Record<string, string> }) => Response;
76
- text: (text: string, options?: { status?: number; headers?: Record<string, string> }) => Response;
77
- redirect: (url: string, options?: { status?: number; headers?: Record<string, string> }) => Response;
78
- error: (error: Error, options?: { status?: number; headers?: Record<string, string> }) => Response;
79
- notFound: (options?: { status?: number; headers?: Record<string, string> }) => Response;
80
- merge: (response: Response) => Response;
81
- raw: Response;
82
- };
83
+ route: RouteConfig;
84
+ req: HSRequest;
85
+ res: HSResponse;
83
86
  };
84
87
 
85
88
  export type ClientIslandOptions = {
@@ -88,9 +91,13 @@ export namespace Hyperspan {
88
91
  };
89
92
 
90
93
  export type RouteConfig = {
91
- name?: string;
92
- path?: string;
93
- cssImports?: string[];
94
+ name: string | undefined;
95
+ path: string;
96
+ params: Record<string, string | undefined>;
97
+ cssImports: string[];
98
+ responseOptions?: {
99
+ disableStreaming?: (context: Hyperspan.Context) => boolean;
100
+ };
94
101
  };
95
102
  export type RouteHandler = (context: Hyperspan.Context) => unknown;
96
103
  export type RouteHandlerOptions = {
@@ -112,6 +119,11 @@ export namespace Hyperspan {
112
119
  */
113
120
  export type NextFunction = () => Promise<Response>;
114
121
 
122
+ /**
123
+ * Error handler function signature
124
+ */
125
+ export type ErrorHandler = (context: Hyperspan.Context, error: Error) => unknown | undefined;
126
+
115
127
  /**
116
128
  * Middleware function signature
117
129
  * Accepts context and next function, returns a Response
@@ -123,7 +135,8 @@ export namespace Hyperspan {
123
135
 
124
136
  export interface Route {
125
137
  _kind: 'hsRoute';
126
- _config: Hyperspan.RouteConfig;
138
+ _config: Partial<Hyperspan.RouteConfig>;
139
+ _serverConfig?: Hyperspan.Config;
127
140
  _path(): string;
128
141
  _methods(): string[];
129
142
  get: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
@@ -132,7 +145,7 @@ export namespace Hyperspan {
132
145
  patch: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
133
146
  delete: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
134
147
  options: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
135
- errorHandler: (handler: Hyperspan.RouteHandler) => Hyperspan.Route;
148
+ errorHandler: (handler: Hyperspan.ErrorHandler) => Hyperspan.Route;
136
149
  middleware: (middleware: Array<Hyperspan.MiddlewareFunction>) => Hyperspan.Route;
137
150
  fetch: (request: Request) => Promise<Response>;
138
151
  };
@@ -147,7 +160,7 @@ export namespace Hyperspan {
147
160
  ) => ActionResponse;
148
161
  export interface Action<T extends z.ZodTypeAny> {
149
162
  _kind: 'hsAction';
150
- _config: Hyperspan.RouteConfig;
163
+ _config: Partial<Hyperspan.RouteConfig>;
151
164
  _path(): string;
152
165
  _form: null | ActionFormHandler<T>;
153
166
  form(form: ActionFormHandler<T>): Action<T>;
@@ -157,4 +170,19 @@ export namespace Hyperspan {
157
170
  middleware: (middleware: Array<Hyperspan.MiddlewareFunction>) => Action<T>;
158
171
  fetch: (request: Request) => Promise<Response>;
159
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
+ }
160
188
  }
package/src/utils.test.ts CHANGED
@@ -149,13 +149,13 @@ describe('parsePath', () => {
149
149
  test('parsePath handles catch-all param with spread', () => {
150
150
  const result = parsePath('users/[...slug]');
151
151
  expect(result.path).toBe('/users/*');
152
- expect(result.params).toEqual(['slug']);
152
+ expect(result.params).toEqual(['...slug']);
153
153
  });
154
154
 
155
155
  test('parsePath handles catch-all param at root', () => {
156
156
  const result = parsePath('[...slug]');
157
157
  expect(result.path).toBe('/*');
158
- expect(result.params).toEqual(['slug']);
158
+ expect(result.params).toEqual(['...slug']);
159
159
  });
160
160
 
161
161
  test('parsePath preserves param names in path but converts format', () => {
package/src/utils.ts CHANGED
@@ -33,7 +33,7 @@ export function parsePath(urlPath: string): { path: string, params: string[] } {
33
33
  // Dynamic params
34
34
  if (ROUTE_SEGMENT_REGEX.test(urlPath)) {
35
35
  urlPath = urlPath.replace(ROUTE_SEGMENT_REGEX, (match: string) => {
36
- const paramName = match.replace(/[^a-zA-Z_\.]+/g, '').replace('...', '');
36
+ const paramName = match.replace(/[^a-zA-Z_\.]+/g, '');
37
37
  params.push(paramName);
38
38
 
39
39
  if (match.includes('...')) {
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
- }