@hyperspan/framework 1.0.29 → 2.0.0-alpha.2

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.2",
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
  ".": {
@@ -37,6 +39,10 @@
37
39
  "types": "./src/client/js.ts",
38
40
  "default": "./src/client/js.ts"
39
41
  },
42
+ "./client/manifest": {
43
+ "types": "./src/client/manifest.ts",
44
+ "default": "./src/client/manifest.ts"
45
+ },
40
46
  "./actions": {
41
47
  "types": "./src/actions.ts",
42
48
  "default": "./src/actions.ts"
@@ -48,6 +54,15 @@
48
54
  "./ssr/mock-dom": {
49
55
  "types": "./src/ssr/mock-dom.ts",
50
56
  "default": "./src/ssr/mock-dom.ts"
57
+ },
58
+ "./client/_hs/hyperspan-streaming.client.ts": {
59
+ "default": "./src/client/_hs/hyperspan-streaming.client.ts"
60
+ },
61
+ "./client/_hs/hyperspan-actions.client.ts": {
62
+ "default": "./src/client/_hs/hyperspan-actions.client.ts"
63
+ },
64
+ "./client/_hs/hyperspan-scripts.client.ts": {
65
+ "default": "./src/client/_hs/hyperspan-scripts.client.ts"
51
66
  }
52
67
  },
53
68
  "author": "Vance Lucas <vance@vancelucas.com>",
@@ -55,7 +70,7 @@
55
70
  "keywords": [
56
71
  "framework",
57
72
  "node",
58
- "bun",
73
+ "vite",
59
74
  "web framework",
60
75
  "javascript",
61
76
  "typescript",
@@ -71,17 +86,17 @@
71
86
  "url": "https://github.com/vlucas/hyperspan/issues"
72
87
  },
73
88
  "scripts": {
74
- "test": "bun test"
89
+ "test": "vitest run"
75
90
  },
76
91
  "devDependencies": {
77
- "@types/bun": "^1.3.1",
78
92
  "@types/debug": "^4.1.12",
79
93
  "@types/node": "^24.10.0",
80
94
  "prettier": "^3.5.2",
81
- "typescript": "^5.9.3"
95
+ "typescript": "^5.9.3",
96
+ "vitest": "^3.2.4"
82
97
  },
83
98
  "dependencies": {
84
- "@hyperspan/html": "^1.0.1",
99
+ "@hyperspan/html": "^2.0.0-alpha.2",
85
100
  "debug": "^4.4.3",
86
101
  "isbot": "^5.1.32",
87
102
  "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) {
@@ -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,73 @@
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 } 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
+ resetClientJSEntriesForTests,
12
+ } from './js';
13
+
14
+ describe('buildClientJS', () => {
15
+ let clientFile: string;
16
+
17
+ beforeEach(() => {
18
+ resetClientJSEntriesForTests();
19
+ const dir = mkdtempSync(join(tmpdir(), 'hs-client-'));
20
+ clientFile = join(dir, 'picker.ts');
21
+ writeFileSync(
22
+ clientFile,
23
+ `export function mountPicker() {
24
+ document.getElementById('picker')?.classList.add('ready');
25
+ }
26
+ `
27
+ );
28
+ });
29
+
30
+ test('registers entry and returns stable esmName from path hash', async () => {
31
+ const result = await buildClientJS(clientFile);
32
+
33
+ expect(result.esmName).toMatch(/^client-[0-9a-f]{16}$/);
34
+ expect(result.publicPath).toBe(`/_hs/js/${result.esmName}.js`);
35
+ expect(getClientJSEntries()).toHaveLength(1);
36
+ expect(getClientJSEntries()[0].absPath).toBe(clientFile);
37
+ });
38
+
39
+ test('renderScriptTag with no loader uses import map key', async () => {
40
+ const result = await buildClientJS(clientFile);
41
+ const tag = render(result.renderScriptTag());
42
+
43
+ expect(tag).toContain(`import "${result.esmName}"`);
44
+ expect(tag).toContain(`data-source-id="${result.assetHash}"`);
45
+ });
46
+
47
+ test('renderScriptTag with function inlines bootstrap code', async () => {
48
+ const result = await buildClientJS(clientFile);
49
+ const tag = render(
50
+ result.renderScriptTag(({ mountPicker }: { mountPicker: () => void }) => {
51
+ mountPicker();
52
+ })
53
+ );
54
+
55
+ expect(tag).toContain(`import {mountPicker} from "${result.esmName}"`);
56
+ expect(tag).toContain('mountPicker()');
57
+ });
58
+
59
+ test('renderScriptTag with string inlines bootstrap code', async () => {
60
+ const result = await buildClientJS(clientFile);
61
+ const tag = render(result.renderScriptTag('({ mountPicker }) => mountPicker()'));
62
+
63
+ expect(tag).toContain('({ mountPicker }) => mountPicker()');
64
+ });
65
+ });
3
66
 
4
67
  describe('extractExports', () => {
5
68
  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};';
69
+ const contents =
70
+ '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
71
 
8
72
  const result = extractExports(contents);
9
73
 
@@ -13,7 +77,7 @@ describe('extractExports', () => {
13
77
  });
14
78
  });
15
79
 
16
- test('returns empty exports when none found', () => {
80
+ test('returns namespace export when none found', () => {
17
81
  const contents = 'function noop(){return 1}const value=2;';
18
82
 
19
83
  const result = extractExports(contents);
@@ -25,3 +89,13 @@ describe('extractExports', () => {
25
89
  });
26
90
  });
27
91
 
92
+ describe('discoverClientExports', () => {
93
+ test('discovers named exports from source', () => {
94
+ const result = discoverClientExports(
95
+ `export function mountPicker() {}\nexport const VERSION = 1;`
96
+ );
97
+
98
+ expect(result.exports).toBe('{mountPicker, VERSION}');
99
+ expect(result.fnArgs).toBe('{mountPicker, VERSION}');
100
+ });
101
+ });
package/src/client/js.ts CHANGED
@@ -1,70 +1,116 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
1
3
  import { html } from '@hyperspan/html';
2
4
  import { assetHash as assetHashFn } from '../utils';
3
- import { join } from 'node:path';
5
+ import { getImportMap, registerImport, resolveImport } from './manifest';
4
6
  import type { Hyperspan as HS } from '../types';
5
7
 
6
- const CWD = process.cwd();
7
- const IS_PROD = process.env.NODE_ENV === 'production';
8
+ export { registerImport, resolveImport, getImportMap } from './manifest';
8
9
 
9
10
  export const JS_PUBLIC_PATH = '/_hs/js';
10
11
  export const JS_ISLAND_PUBLIC_PATH = '/_hs/js/islands';
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 CLIENT_JS_BUILD_PROMISES = new Map<string, Promise<void>>();
14
- const EXPORT_REGEX = /export\{(.*)\}/g;
12
+
13
+ export type ClientJSEntry = {
14
+ absPath: string;
15
+ assetHash: string;
16
+ esmName: string;
17
+ publicPath: string;
18
+ exports: string;
19
+ fnArgs: string;
20
+ };
21
+
22
+ const CLIENT_JS_REGISTRY = Symbol.for('@hyperspan/client-js-entries');
23
+
24
+ function getClientJSRegistry(): Map<string, ClientJSEntry> {
25
+ const globalRegistry = globalThis as {
26
+ [CLIENT_JS_REGISTRY]?: Map<string, ClientJSEntry>;
27
+ };
28
+ if (!globalRegistry[CLIENT_JS_REGISTRY]) {
29
+ globalRegistry[CLIENT_JS_REGISTRY] = new Map();
30
+ }
31
+ return globalRegistry[CLIENT_JS_REGISTRY];
32
+ }
33
+
34
+ /**
35
+ * Backward-compatible import map (backed by asset manifest).
36
+ */
37
+ export const JS_IMPORT_MAP = {
38
+ get size() {
39
+ return Object.keys(getImportMap()).length;
40
+ },
41
+ keys() {
42
+ return Object.keys(getImportMap()).values();
43
+ },
44
+ has(key: string) {
45
+ return resolveImport(key) !== undefined;
46
+ },
47
+ get(key: string) {
48
+ return resolveImport(key);
49
+ },
50
+ set(key: string, value: string) {
51
+ registerImport(key, value);
52
+ return this;
53
+ },
54
+ entries() {
55
+ return Object.entries(getImportMap()).values();
56
+ },
57
+ [Symbol.iterator]() {
58
+ return Object.entries(getImportMap())[Symbol.iterator]();
59
+ },
60
+ };
61
+
62
+ /**
63
+ * Resolve import.meta.resolve() / file URL / absolute path to a filesystem path.
64
+ */
65
+ export function resolveClientModulePath(modulePathResolved: string): string {
66
+ if (modulePathResolved.startsWith('file://')) {
67
+ return fileURLToPath(modulePathResolved);
68
+ }
69
+ return modulePathResolved;
70
+ }
71
+
72
+ export function getClientJSEntries(): ClientJSEntry[] {
73
+ return [...getClientJSRegistry().values()];
74
+ }
75
+
76
+ export function getClientJSEntryByEsmName(esmName: string): ClientJSEntry | undefined {
77
+ return getClientJSRegistry().get(esmName);
78
+ }
79
+
80
+ export function resetClientJSEntriesForTests(): void {
81
+ getClientJSRegistry().clear();
82
+ }
83
+
84
+ function registerClientJSEntry(entry: ClientJSEntry): void {
85
+ getClientJSRegistry().set(entry.esmName, entry);
86
+ }
15
87
 
16
88
  /**
17
- * Build a client JS module and return a Hyperspan.ClientJSBuildResult object
89
+ * Build (or look up) a client JS module and return a helper for rendering script tags.
90
+ *
91
+ * Never evaluates the module on the server — only registers the path for Vite to bundle
92
+ * and returns URLs / script-tag helpers for the browser.
18
93
  */
19
94
  export async function buildClientJS(modulePathResolved: string): Promise<HS.ClientJSBuildResult> {
20
- const modulePath = modulePathResolved.replace('file://', '');
21
- const assetHash = assetHashFn(modulePath);
22
-
23
- // Cache: Avoid re-processing the same file
24
- if (!CLIENT_JS_CACHE.has(assetHash)) {
25
- const existingBuild = CLIENT_JS_BUILD_PROMISES.get(assetHash);
26
- // Await the existing build promise if it exists (this can get called in parallel from Bun traversing imports)
27
- if (existingBuild) {
28
- await existingBuild;
29
- } else {
30
- const buildPromise = (async () => {
31
- // Build the client JS module
32
- const result = await Bun.build({
33
- entrypoints: [modulePath],
34
- outdir: join(CWD, './public', JS_PUBLIC_PATH), // @TODO: Make this configurable... should be read from config file...
35
- naming: IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined,
36
- external: Array.from(JS_IMPORT_MAP.keys()),
37
- minify: true,
38
- format: 'esm',
39
- target: 'browser',
40
- env: 'APP_PUBLIC_*',
41
- });
42
-
43
- // Add output file to import map
44
- const esmName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', '');
45
- const publicPath = `${JS_PUBLIC_PATH}/${esmName}.js`;
46
- JS_IMPORT_MAP.set(esmName, publicPath);
47
-
48
- // Get the contents of the file to extract the exports
49
- const contents = await result.outputs[0].text();
50
- const { exports, fnArgs } = extractExports(contents);
51
-
52
- CLIENT_JS_CACHE.set(assetHash, { esmName, exports, fnArgs, publicPath });
53
- })();
54
-
55
- CLIENT_JS_BUILD_PROMISES.set(assetHash, buildPromise);
56
- try {
57
- await buildPromise;
58
- } finally {
59
- CLIENT_JS_BUILD_PROMISES.delete(assetHash);
60
- }
61
- }
95
+ const absPath = resolveClientModulePath(modulePathResolved);
96
+ const hash = assetHashFn(absPath);
97
+ const esmName = `client-${hash}`;
98
+ const publicPath = resolveImport(esmName) ?? `${JS_PUBLIC_PATH}/${esmName}.js`;
99
+
100
+ let exports = '* as _module';
101
+ let fnArgs = '_module';
102
+ if (existsSync(absPath)) {
103
+ const source = readFileSync(absPath, 'utf-8');
104
+ const discovered = discoverClientExports(source);
105
+ exports = discovered.exports;
106
+ fnArgs = discovered.fnArgs;
62
107
  }
63
108
 
64
- const { esmName, exports, fnArgs, publicPath } = CLIENT_JS_CACHE.get(assetHash)!;
109
+ registerClientJSEntry({ absPath, assetHash: hash, esmName, publicPath, exports, fnArgs });
110
+ registerImport(esmName, publicPath);
65
111
 
66
112
  return {
67
- assetHash,
113
+ assetHash: hash,
68
114
  esmName,
69
115
  publicPath,
70
116
  renderScriptTag: (loadScript) => {
@@ -72,27 +118,90 @@ export async function buildClientJS(modulePathResolved: string): Promise<HS.Clie
72
118
 
73
119
  if (t === 'string') {
74
120
  return html`
75
- <script type="module" data-source-id="${assetHash}">import ${exports} from "${esmName}";\n(${html.raw(loadScript as string)})(${fnArgs});</script>
121
+ <script type="module" data-source-id="${hash}">
122
+ import ${exports} from '${esmName}';
123
+ (${html.raw(loadScript as string)})(${fnArgs});
124
+ </script>
76
125
  `;
77
126
  }
78
127
  if (t === 'function') {
79
128
  return html`
80
- <script type="module" data-source-id="${assetHash}">import ${exports} from "${esmName}";\n(${html.raw(functionToString(loadScript))})(${fnArgs});</script>
129
+ <script type="module" data-source-id="${hash}">
130
+ import ${exports} from '${esmName}';
131
+ (${html.raw(functionToString(loadScript))})(${fnArgs});
132
+ </script>
81
133
  `;
82
134
  }
83
135
 
84
136
  return html`
85
- <script type="module" data-source-id="${assetHash}">import "${esmName}";</script>
137
+ <script type="module" data-source-id="${hash}">
138
+ import '${esmName}';
139
+ </script>
86
140
  `;
141
+ },
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Discover export names from source or bundled client JS.
147
+ * Prefers bundled `export{...}` form, then common source export patterns.
148
+ */
149
+ export function discoverClientExports(contents: string): { exports: string; fnArgs: string } {
150
+ const bundled = extractExports(contents);
151
+ if (bundled.exports !== '* as _module') {
152
+ return bundled;
153
+ }
154
+
155
+ const names: string[] = [];
156
+ const seen = new Set<string>();
157
+
158
+ for (const re of [
159
+ /export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g,
160
+ /export\s+const\s+([A-Za-z_$][\w$]*)/g,
161
+ /export\s+class\s+([A-Za-z_$][\w$]*)/g,
162
+ /export\s+let\s+([A-Za-z_$][\w$]*)/g,
163
+ ]) {
164
+ let match: RegExpExecArray | null;
165
+ while ((match = re.exec(contents)) !== null) {
166
+ if (!seen.has(match[1])) {
167
+ names.push(match[1]);
168
+ seen.add(match[1]);
169
+ }
87
170
  }
88
171
  }
172
+
173
+ const exportBlock = /export\s*\{([^}]+)\}/g;
174
+ let block: RegExpExecArray | null;
175
+ while ((block = exportBlock.exec(contents)) !== null) {
176
+ for (const part of block[1].split(',')) {
177
+ const trimmed = part.trim();
178
+ if (!trimmed || trimmed.startsWith('type ')) continue;
179
+ const asMatch = trimmed.match(/^(?:([\w$]+)\s+as\s+)?([\w$]+)$/);
180
+ const name = asMatch?.[2] ?? trimmed;
181
+ if (name && name !== 'default' && !seen.has(name)) {
182
+ names.push(name);
183
+ seen.add(name);
184
+ }
185
+ }
186
+ }
187
+
188
+ if (names.length === 0) {
189
+ if (/export\s+default\b/.test(contents)) {
190
+ return { exports: 'DefaultExport', fnArgs: 'DefaultExport' };
191
+ }
192
+ return { exports: '* as _module', fnArgs: '_module' };
193
+ }
194
+
195
+ const exports = `{${names.join(', ')}}`;
196
+ return { exports, fnArgs: exports };
89
197
  }
90
198
 
91
199
  /**
92
- * Extract the exports from a client JS module
200
+ * Extract the exports from a client JS module (bundled `export{a as b}` form).
93
201
  */
94
- export function extractExports(contents: string): { exports: string, fnArgs: string } {
95
- const exportLine = EXPORT_REGEX.exec(contents);
202
+ export function extractExports(contents: string): { exports: string; fnArgs: string } {
203
+ const exportRegex = /export\{(.*)\}/g;
204
+ const exportLine = exportRegex.exec(contents);
96
205
  let exports = '{}';
97
206
  let fnArgs = '{}';
98
207
 
@@ -111,8 +220,8 @@ export function extractExports(contents: string): { exports: string, fnArgs: str
111
220
  fnArgs = exports.replace(/(\w+)\s*as\s*(\w+)/g, '$1: $2').trim();
112
221
 
113
222
  if (exports === '{}' && fnArgs === '{}') {
114
- exports = '* as _module'
115
- fnArgs = '_module'
223
+ exports = '* as _module';
224
+ fnArgs = '_module';
116
225
  }
117
226
 
118
227
  return { exports, fnArgs };
@@ -120,8 +229,7 @@ export function extractExports(contents: string): { exports: string, fnArgs: str
120
229
 
121
230
  /**
122
231
  * Convert a function to a string (results in loss of context!)
123
- * Handles named, async, and arrow functions
124
232
  */
125
- export function functionToString(fn: any) {
126
- return fn.toString().trim();
127
- }
233
+ export function functionToString(fn: unknown) {
234
+ return (fn as (...args: unknown[]) => unknown).toString().trim();
235
+ }