@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 +22 -7
- package/src/actions.test.ts +1 -1
- package/src/actions.ts +3 -6
- package/src/client/_hs/hyperspan-actions.client.ts +37 -45
- package/src/client/js.test.ts +78 -4
- package/src/client/js.ts +172 -64
- package/src/client/manifest.ts +87 -0
- package/src/fetch-handler.test.ts +90 -0
- package/src/fetch-handler.ts +167 -0
- package/src/index.ts +14 -0
- package/src/island.test.ts +30 -0
- package/src/island.ts +37 -0
- package/src/layout.ts +34 -9
- package/src/server.test.ts +12 -1
- package/src/server.ts +6 -2
- package/src/ssr/mock-dom.test.ts +1 -1
- package/src/types.ts +12 -0
- package/src/utils.test.ts +72 -2
- package/src/utils.ts +66 -10
- package/tsconfig.json +4 -8
package/package.json
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspan/framework",
|
|
3
|
-
"version": "
|
|
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
|
-
"
|
|
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": "
|
|
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": "^
|
|
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"
|
package/src/actions.test.ts
CHANGED
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 {
|
|
6
|
+
import { hyperspanActionsScriptTag } from './layout';
|
|
7
7
|
import { validateBody, ZodValidationError } from './middleware';
|
|
8
|
-
import
|
|
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
|
-
>${
|
|
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
|
|
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
|
-
|
|
94
|
-
*/
|
|
95
|
-
function
|
|
96
|
-
if (
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
e
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
115
|
-
function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOptons) {
|
|
108
|
+
function formSubmitToRoute(form: HTMLFormElement) {
|
|
116
109
|
const formData = new FormData(form);
|
|
117
|
-
const
|
|
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
|
|
package/src/client/js.test.ts
CHANGED
|
@@ -1,9 +1,73 @@
|
|
|
1
|
-
import { describe, expect, test } from '
|
|
2
|
-
import {
|
|
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 =
|
|
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
|
|
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 {
|
|
5
|
+
import { getImportMap, registerImport, resolveImport } from './manifest';
|
|
4
6
|
import type { Hyperspan as HS } from '../types';
|
|
5
7
|
|
|
6
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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="${
|
|
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="${
|
|
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="${
|
|
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
|
|
95
|
-
const
|
|
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:
|
|
126
|
-
return fn.toString().trim();
|
|
127
|
-
}
|
|
233
|
+
export function functionToString(fn: unknown) {
|
|
234
|
+
return (fn as (...args: unknown[]) => unknown).toString().trim();
|
|
235
|
+
}
|