@hyperspan/framework 1.0.29 → 2.0.0-alpha.13

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,12 +1,14 @@
1
1
  {
2
2
  "name": "@hyperspan/framework",
3
- "version": "1.0.29",
3
+ "version": "2.0.0-alpha.13",
4
4
  "description": "Hyperspan Web Framework",
5
+ "type": "module",
5
6
  "main": "src/server.ts",
6
7
  "types": "src/server.ts",
7
8
  "public": true,
8
9
  "publishConfig": {
9
- "access": "public"
10
+ "access": "public",
11
+ "tag": "alpha"
10
12
  },
11
13
  "exports": {
12
14
  ".": {
@@ -25,10 +27,18 @@
25
27
  "types": "./src/utils.ts",
26
28
  "default": "./src/utils.ts"
27
29
  },
30
+ "./routes": {
31
+ "types": "./src/register-routes.ts",
32
+ "default": "./src/register-routes.ts"
33
+ },
28
34
  "./layout": {
29
35
  "types": "./src/layout.ts",
30
36
  "default": "./src/layout.ts"
31
37
  },
38
+ "./dev-bindings": {
39
+ "types": "./src/dev-bindings.ts",
40
+ "default": "./src/dev-bindings.ts"
41
+ },
32
42
  "./client/css": {
33
43
  "types": "./src/client/css.ts",
34
44
  "default": "./src/client/css.ts"
@@ -37,6 +47,10 @@
37
47
  "types": "./src/client/js.ts",
38
48
  "default": "./src/client/js.ts"
39
49
  },
50
+ "./client/manifest": {
51
+ "types": "./src/client/manifest.ts",
52
+ "default": "./src/client/manifest.ts"
53
+ },
40
54
  "./actions": {
41
55
  "types": "./src/actions.ts",
42
56
  "default": "./src/actions.ts"
@@ -48,6 +62,15 @@
48
62
  "./ssr/mock-dom": {
49
63
  "types": "./src/ssr/mock-dom.ts",
50
64
  "default": "./src/ssr/mock-dom.ts"
65
+ },
66
+ "./client/_hs/hyperspan-streaming.client.ts": {
67
+ "default": "./src/client/_hs/hyperspan-streaming.client.ts"
68
+ },
69
+ "./client/_hs/hyperspan-actions.client.ts": {
70
+ "default": "./src/client/_hs/hyperspan-actions.client.ts"
71
+ },
72
+ "./client/_hs/hyperspan-scripts.client.ts": {
73
+ "default": "./src/client/_hs/hyperspan-scripts.client.ts"
51
74
  }
52
75
  },
53
76
  "author": "Vance Lucas <vance@vancelucas.com>",
@@ -55,7 +78,7 @@
55
78
  "keywords": [
56
79
  "framework",
57
80
  "node",
58
- "bun",
81
+ "vite",
59
82
  "web framework",
60
83
  "javascript",
61
84
  "typescript",
@@ -71,17 +94,17 @@
71
94
  "url": "https://github.com/vlucas/hyperspan/issues"
72
95
  },
73
96
  "scripts": {
74
- "test": "bun test"
97
+ "test": "vitest run"
75
98
  },
76
99
  "devDependencies": {
77
- "@types/bun": "^1.3.1",
78
100
  "@types/debug": "^4.1.12",
79
101
  "@types/node": "^24.10.0",
80
102
  "prettier": "^3.5.2",
81
- "typescript": "^5.9.3"
103
+ "typescript": "^5.9.3",
104
+ "vitest": "^4.1.10"
82
105
  },
83
106
  "dependencies": {
84
- "@hyperspan/html": "^1.0.1",
107
+ "@hyperspan/html": "^2.0.0-alpha.13",
85
108
  "debug": "^4.4.3",
86
109
  "isbot": "^5.1.32",
87
110
  "zod": "^4.4.3"
@@ -1,4 +1,4 @@
1
- import { test, expect, describe } from 'bun:test';
1
+ import { test, expect, describe } from 'vitest';
2
2
  import { createAction } from './actions';
3
3
  import { createRoute } from './server';
4
4
  import { html, render, placeholder, type HSHtml } from '@hyperspan/html';
package/src/actions.ts CHANGED
@@ -3,14 +3,11 @@ import { createRoute, HTTPResponseException, returnHTMLResponse } from './server
3
3
  import * as z from 'zod';
4
4
  import type { Hyperspan as HS } from './types';
5
5
  import { assetHash, formDataToJSON } from './utils';
6
- import { buildClientJS } from './client/js';
6
+ import { hyperspanActionsScriptTag } from './layout';
7
7
  import { validateBody, ZodValidationError } from './middleware';
8
- import { debug } from 'debug';
8
+ import debug from 'debug';
9
9
 
10
10
  const log = debug('hyperspan:actions');
11
- const actionsClientJS = await buildClientJS(
12
- import.meta.resolve('./client/_hs/hyperspan-actions.client')
13
- );
14
11
 
15
12
  /**
16
13
  * Actions = Form + route handler
@@ -138,7 +135,7 @@ export function createAction<S extends z.ZodType>(params: {
138
135
  const formContent = api._form ? api._form(c, props || {}) : null;
139
136
  return formContent
140
137
  ? html`<hs-action url="${this._path()}">${formContent}</hs-action
141
- >${actionsClientJS.renderScriptTag()}`
138
+ >${hyperspanActionsScriptTag()}`
142
139
  : null;
143
140
  },
144
141
  errorHandler(handler) {
@@ -0,0 +1,23 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { Adapter } from './types';
3
+ import { createConfig } from './server';
4
+
5
+ const cloudflare: Adapter = {
6
+ name: 'cloudflare',
7
+ createEntry() {
8
+ return {
9
+ fetch: async () => new Response('ok'),
10
+ };
11
+ },
12
+ };
13
+
14
+ describe('createConfig deployAdapter', () => {
15
+ test('defaults to Node when omitted or undefined', () => {
16
+ expect(createConfig({}).deployAdapter.name).toBe('node');
17
+ expect(createConfig({ deployAdapter: undefined }).deployAdapter.name).toBe('node');
18
+ });
19
+
20
+ test('keeps an explicit adapter', () => {
21
+ expect(createConfig({ deployAdapter: cloudflare }).deployAdapter).toBe(cloudflare);
22
+ });
23
+ });
@@ -59,62 +59,56 @@ function setActionLoading(hsActionTag: HTMLElement | null, loading: boolean) {
59
59
  }
60
60
  }
61
61
 
62
- const actionFormObserver = new MutationObserver((list) => {
63
- list.forEach((mutation) => {
64
- mutation.addedNodes.forEach((node) => {
65
- if (node && ('closest' in node || node instanceof HTMLFormElement)) {
66
- bindHSActionForm(
67
- (node as HTMLElement).closest('hs-action') as HSAction,
68
- node instanceof HTMLFormElement
69
- ? node
70
- : ((node as HTMLElement | HTMLFormElement).querySelector('form') as HTMLFormElement)
71
- );
72
- }
73
- });
74
- });
75
- });
76
-
77
62
  /**
78
- * Server action component to handle the client-side form submission and HTML replacement
63
+ * Server action component — holds the action URL; form submit is handled via delegation.
79
64
  */
80
- class HSAction extends HTMLElement {
81
- constructor() {
82
- super();
83
- }
84
-
85
- connectedCallback() {
86
- actionFormObserver.observe(this, { childList: true, subtree: true });
87
- bindHSActionForm(this, this.querySelector('form') as HTMLFormElement);
88
- }
89
- }
65
+ class HSAction extends HTMLElement {}
90
66
  window.customElements.define('hs-action', HSAction);
91
67
 
92
- /**
93
- * Bind the form inside an hs-action element to the action URL and submit handler
94
- */
95
- function bindHSActionForm(hsActionElement: HSAction, form: HTMLFormElement) {
96
- if (!hsActionElement || !form) {
68
+ let actionDelegationInitialized = false;
69
+
70
+ /** One document-level handler so morphs never leave forms without listeners or stack duplicates. */
71
+ function initHSActionFormDelegation() {
72
+ if (actionDelegationInitialized) {
97
73
  return;
98
74
  }
75
+ actionDelegationInitialized = true;
99
76
 
100
- form.setAttribute('action', hsActionElement.getAttribute('url') || '');
101
- const submitHandler = (e: Event) => {
102
- e.preventDefault();
103
- formSubmitToRoute(e, form as HTMLFormElement, {
104
- afterResponse: () => bindHSActionForm(hsActionElement, form),
105
- });
106
- form.removeEventListener('submit', submitHandler);
107
- };
108
- form.addEventListener('submit', submitHandler);
77
+ document.addEventListener(
78
+ 'submit',
79
+ (e) => {
80
+ const form = e.target;
81
+ if (!(form instanceof HTMLFormElement)) {
82
+ return;
83
+ }
84
+
85
+ const hsAction = form.closest('hs-action');
86
+ if (!hsAction) {
87
+ return;
88
+ }
89
+
90
+ e.preventDefault();
91
+
92
+ const url = hsAction.getAttribute('url') || '';
93
+ if (url) {
94
+ form.setAttribute('action', url);
95
+ }
96
+
97
+ formSubmitToRoute(form);
98
+ },
99
+ true
100
+ );
109
101
  }
110
102
 
103
+ initHSActionFormDelegation();
104
+
111
105
  /**
112
106
  * Submit form data to route and replace contents with response
113
107
  */
114
- type TFormSubmitOptons = { afterResponse: () => any };
115
- function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOptons) {
108
+ function formSubmitToRoute(form: HTMLFormElement) {
116
109
  const formData = new FormData(form);
117
- const formUrl = form.getAttribute('action') || '';
110
+ const hsActionTag = form.closest('hs-action') as HTMLElement | null;
111
+ const formUrl = hsActionTag?.getAttribute('url') || form.getAttribute('action') || '';
118
112
  const method = form.getAttribute('method')?.toUpperCase() || 'POST';
119
113
  const confirmMessage = form.getAttribute('data-confirm') || '';
120
114
  const headers = {
@@ -129,7 +123,6 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
129
123
  }
130
124
  }
131
125
 
132
- const hsActionTag = form.closest('hs-action') as HTMLElement | null;
133
126
  const eventTarget: EventTarget = hsActionTag || document;
134
127
  const fetchDetail: HS.ActionFetchDetail = {
135
128
  form,
@@ -185,7 +178,6 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
185
178
 
186
179
  dispatchActionEvent(eventTarget, HS_ACTION_AFTER_SWAP, swapDetail);
187
180
 
188
- opts.afterResponse && opts.afterResponse();
189
181
  lazyLoadScripts();
190
182
  }
191
183
 
@@ -1,9 +1,152 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import { extractExports } from './js';
1
+ import { describe, expect, test, beforeEach } from 'vitest';
2
+ import { mkdtempSync, writeFileSync, existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { render } from '@hyperspan/html';
6
+ import {
7
+ buildClientJS,
8
+ discoverClientExports,
9
+ extractExports,
10
+ getClientJSEntries,
11
+ registerPathAliases,
12
+ resetClientJSEntriesForTests,
13
+ } from './js';
14
+
15
+ describe('buildClientJS', () => {
16
+ let clientFile: string;
17
+
18
+ beforeEach(() => {
19
+ resetClientJSEntriesForTests();
20
+ const dir = mkdtempSync(join(tmpdir(), 'hs-client-'));
21
+ clientFile = join(dir, 'picker.ts');
22
+ writeFileSync(
23
+ clientFile,
24
+ `export function mountPicker() {
25
+ document.getElementById('picker')?.classList.add('ready');
26
+ }
27
+ `
28
+ );
29
+ });
30
+
31
+ test('registers entry and returns stable esmName from path hash', async () => {
32
+ const result = await buildClientJS(clientFile);
33
+
34
+ expect(result.esmName).toMatch(/^client-[0-9a-f]{16}$/);
35
+ expect(result.publicPath).toBe(`/_hs/js/${result.esmName}.js`);
36
+ expect(getClientJSEntries()).toHaveLength(1);
37
+ expect(getClientJSEntries()[0].absPath).toBe(clientFile);
38
+ });
39
+
40
+ test('renderScriptTag with no loader uses import map key', async () => {
41
+ const result = await buildClientJS(clientFile);
42
+ const tag = render(result.renderScriptTag());
43
+
44
+ expect(tag).toContain(`import "${result.esmName}"`);
45
+ expect(tag).toContain(`data-source-id="${result.assetHash}"`);
46
+ });
47
+
48
+ test('renderScriptTag with function inlines bootstrap code', async () => {
49
+ const result = await buildClientJS(clientFile);
50
+ const tag = render(
51
+ result.renderScriptTag(({ mountPicker }: { mountPicker: () => void }) => {
52
+ mountPicker();
53
+ })
54
+ );
55
+
56
+ expect(tag).toContain(`import {mountPicker} from "${result.esmName}"`);
57
+ expect(tag).toContain('mountPicker()');
58
+ });
59
+
60
+ test('renderScriptTag with string inlines bootstrap code', async () => {
61
+ const result = await buildClientJS(clientFile);
62
+ const tag = render(result.renderScriptTag('({ mountPicker }) => mountPicker()'));
63
+
64
+ expect(tag).toContain('({ mountPicker }) => mountPicker()');
65
+ });
66
+
67
+ test('import.meta.resolve at the call site registers a real file', async () => {
68
+ const result = await buildClientJS(import.meta.resolve('./_hs/hyperspan-streaming.client.ts'), {
69
+ type: 'iife',
70
+ });
71
+
72
+ expect(result.publicPath).toMatch(/^\/_hs\/js\/client-[0-9a-f]{16}\.js$/);
73
+ expect(getClientJSEntries()[0].type).toBe('iife');
74
+ expect(existsSync(getClientJSEntries()[0].absPath)).toBe(true);
75
+ expect(getClientJSEntries()[0].absPath).toMatch(/hyperspan-streaming\.client\.ts$/);
76
+ });
77
+
78
+ test('relative paths must be resolved at the call site', async () => {
79
+ await expect(buildClientJS('../client/picker.ts')).rejects.toThrow(/import\.meta\.resolve/);
80
+ await expect(buildClientJS('./picker.ts')).rejects.toThrow(/import\.meta\.resolve/);
81
+ });
82
+
83
+ test('type iife renders a classic script tag', async () => {
84
+ const result = await buildClientJS(clientFile, { type: 'iife' });
85
+ const tag = render(result.renderScriptTag());
86
+
87
+ expect(tag).toContain(`<script src="${result.publicPath}"`);
88
+ expect(tag).not.toContain('type="module"');
89
+ expect(getClientJSEntries()[0].type).toBe('iife');
90
+ });
91
+
92
+ test('logical app-relative paths hash consistently and resolve on disk when present', async () => {
93
+ const dir = mkdtempSync(join(tmpdir(), 'hs-client-logical-'));
94
+ const { mkdirSync } = await import('node:fs');
95
+ mkdirSync(join(dir, 'app/client'), { recursive: true });
96
+ const logicalPath = join(dir, 'app/client/stats.ts');
97
+ writeFileSync(logicalPath, `export function mountStats() {}\n`);
98
+
99
+ const cwd = process.cwd();
100
+ process.chdir(dir);
101
+ try {
102
+ const first = await buildClientJS('app/client/stats.ts');
103
+ resetClientJSEntriesForTests();
104
+ const second = await buildClientJS('app/client/stats.ts');
105
+ expect(first.esmName).toBe(second.esmName);
106
+ expect(getClientJSEntries()[0].absPath.replace(/^\/private/, '')).toBe(
107
+ logicalPath.replace(/^\/private/, '')
108
+ );
109
+ } finally {
110
+ process.chdir(cwd);
111
+ }
112
+ });
113
+
114
+ test('tsconfig aliases resolve inside buildClientJS without import.meta.resolve', async () => {
115
+ const dir = mkdtempSync(join(tmpdir(), 'hs-client-alias-'));
116
+ const { mkdirSync } = await import('node:fs');
117
+ mkdirSync(join(dir, 'app/client'), { recursive: true });
118
+ const absPath = join(dir, 'app/client/stats.ts');
119
+ writeFileSync(absPath, `export function mountStats() {}\n`);
120
+
121
+ registerPathAliases({ '~/': `${dir}/`, '~': dir });
122
+
123
+ const result = await buildClientJS('~/app/client/stats.ts');
124
+ expect(getClientJSEntries()[0].absPath.replace(/^\/private/, '')).toBe(
125
+ absPath.replace(/^\/private/, '')
126
+ );
127
+ expect(getClientJSEntries()[0].modulePath).toBe('~/app/client/stats.ts');
128
+
129
+ resetClientJSEntriesForTests();
130
+ registerPathAliases({ '~/': `${dir}/`, '~': dir });
131
+ const second = await buildClientJS('~/app/client/stats.ts');
132
+ expect(second.esmName).toBe(result.esmName);
133
+ });
134
+
135
+ test('aliased specifiers keep a stable hash when the file is missing', async () => {
136
+ registerPathAliases({ '~/': '/definitely-missing-hyperspan-root/' });
137
+ const first = await buildClientJS('~/app/client/missing.ts');
138
+ resetClientJSEntriesForTests();
139
+ registerPathAliases({ '~/': '/definitely-missing-hyperspan-root/' });
140
+ const second = await buildClientJS('~/app/client/missing.ts');
141
+ expect(first.esmName).toBe(second.esmName);
142
+ expect(existsSync(getClientJSEntries()[0].absPath)).toBe(false);
143
+ });
144
+ });
3
145
 
4
146
  describe('extractExports', () => {
5
147
  test('extracts aliased export from bundled JS content', () => {
6
- const contents = 'function d(n,t){if(t)n.classList.remove("hidden");else n.classList.add("hidden")}function p(){let n=document.querySelector("[data-google-spreadsheet-id]"),t=document.querySelector("[data-google-sheet-select]"),a=document.querySelector("[data-google-sheet-message]");if(console.log("googleSheetsPickerClient",n,t),!n||!t)return;let l=(e)=>{if(!a)return;a.textContent=e||"",d(a,Boolean(e))},h=(e)=>{t.innerHTML="",e.forEach((s)=>{let o=document.createElement("option");o.value=s,o.textContent=s,t.appendChild(o)})},r=(e)=>{d(t,e),t.disabled=!e,t.required=e},c=async()=>{let e=n.value.trim();if(l(null),!e){r(!1);return}try{let s=await fetch(`/api/google-sheets/${encodeURIComponent(e)}`),o=await s.json();if(!s.ok||o.error)throw Error(o.error||"Unable to load sheet names.");let i=(o.sheets||[]).map((u)=>u.title).filter(Boolean);if(i.length===0){l("No sheets found in that spreadsheet."),r(!1);return}h(i),t.value=i[0],r(!0)}catch(s){l(s instanceof Error?s.message:"Unable to load sheet names."),r(!1)}};n.addEventListener("input",c),n.addEventListener("change",c)}export{p as mountGoogleSheetsPicker};';
148
+ const contents =
149
+ 'function d(n,t){if(t)n.classList.remove("hidden");else n.classList.add("hidden")}function p(){let n=document.querySelector("[data-google-spreadsheet-id]"),t=document.querySelector("[data-google-sheet-select]"),a=document.querySelector("[data-google-sheet-message]");if(console.log("googleSheetsPickerClient",n,t),!n||!t)return;let l=(e)=>{if(!a)return;a.textContent=e||"",d(a,Boolean(e))},h=(e)=>{t.innerHTML="",e.forEach((s)=>{let o=document.createElement("option");o.value=s,o.textContent=s,t.appendChild(o)})},r=(e)=>{d(t,e),t.disabled=!e,t.required=e},c=async()=>{let e=n.value.trim();if(l(null),!e){r(!1);return}try{let s=await fetch(`/api/google-sheets/${encodeURIComponent(e)}`),o=await s.json();if(!s.ok||o.error)throw Error(o.error||"Unable to load sheet names.");let i=(o.sheets||[]).map((u)=>u.title).filter(Boolean);if(i.length===0){l("No sheets found in that spreadsheet."),r(!1);return}h(i),t.value=i[0],r(!0)}catch(s){l(s instanceof Error?s.message:"Unable to load sheet names."),r(!1)}};n.addEventListener("input",c),n.addEventListener("change",c)}export{p as mountGoogleSheetsPicker};';
7
150
 
8
151
  const result = extractExports(contents);
9
152
 
@@ -13,7 +156,7 @@ describe('extractExports', () => {
13
156
  });
14
157
  });
15
158
 
16
- test('returns empty exports when none found', () => {
159
+ test('returns namespace export when none found', () => {
17
160
  const contents = 'function noop(){return 1}const value=2;';
18
161
 
19
162
  const result = extractExports(contents);
@@ -25,3 +168,13 @@ describe('extractExports', () => {
25
168
  });
26
169
  });
27
170
 
171
+ describe('discoverClientExports', () => {
172
+ test('discovers named exports from source', () => {
173
+ const result = discoverClientExports(
174
+ `export function mountPicker() {}\nexport const VERSION = 1;`
175
+ );
176
+
177
+ expect(result.exports).toBe('{mountPicker, VERSION}');
178
+ expect(result.fnArgs).toBe('{mountPicker, VERSION}');
179
+ });
180
+ });