@hyperspan/framework 2.0.0-alpha.1 → 2.0.0-alpha.14
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 +11 -3
- package/src/adapter.test.ts +19 -0
- package/src/client/_hs/hyperspan-actions.client.ts +37 -45
- package/src/client/js.test.ts +156 -3
- package/src/client/js.ts +273 -25
- package/src/client/manifest.ts +2 -6
- package/src/dev-bindings.ts +14 -0
- package/src/fetch-handler.test.ts +23 -0
- package/src/fetch-handler.ts +4 -3
- package/src/index.ts +18 -1
- package/src/island.test.ts +30 -0
- package/src/island.ts +37 -0
- package/src/layout.test.ts +12 -0
- package/src/layout.ts +10 -18
- package/src/register-routes.ts +64 -0
- package/src/server.test.ts +48 -1
- package/src/server.ts +37 -10
- package/src/types.ts +77 -3
- package/tsconfig.json +3 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspan/framework",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.14",
|
|
4
4
|
"description": "Hyperspan Web Framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/server.ts",
|
|
@@ -27,10 +27,18 @@
|
|
|
27
27
|
"types": "./src/utils.ts",
|
|
28
28
|
"default": "./src/utils.ts"
|
|
29
29
|
},
|
|
30
|
+
"./routes": {
|
|
31
|
+
"types": "./src/register-routes.ts",
|
|
32
|
+
"default": "./src/register-routes.ts"
|
|
33
|
+
},
|
|
30
34
|
"./layout": {
|
|
31
35
|
"types": "./src/layout.ts",
|
|
32
36
|
"default": "./src/layout.ts"
|
|
33
37
|
},
|
|
38
|
+
"./dev-bindings": {
|
|
39
|
+
"types": "./src/dev-bindings.ts",
|
|
40
|
+
"default": "./src/dev-bindings.ts"
|
|
41
|
+
},
|
|
34
42
|
"./client/css": {
|
|
35
43
|
"types": "./src/client/css.ts",
|
|
36
44
|
"default": "./src/client/css.ts"
|
|
@@ -93,10 +101,10 @@
|
|
|
93
101
|
"@types/node": "^24.10.0",
|
|
94
102
|
"prettier": "^3.5.2",
|
|
95
103
|
"typescript": "^5.9.3",
|
|
96
|
-
"vitest": "^
|
|
104
|
+
"vitest": "^4.1.10"
|
|
97
105
|
},
|
|
98
106
|
"dependencies": {
|
|
99
|
-
"@hyperspan/html": "^2.0.0-alpha.
|
|
107
|
+
"@hyperspan/html": "^2.0.0-alpha.14",
|
|
100
108
|
"debug": "^4.4.3",
|
|
101
109
|
"isbot": "^5.1.32",
|
|
102
110
|
"zod": "^4.4.3"
|
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
renderServerEntry: (template) => template(),
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
describe('createConfig deployAdapter', () => {
|
|
11
|
+
test('defaults to Node when omitted or undefined', () => {
|
|
12
|
+
expect(createConfig({}).deployAdapter.name).toBe('node');
|
|
13
|
+
expect(createConfig({ deployAdapter: undefined }).deployAdapter.name).toBe('node');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('keeps an explicit adapter', () => {
|
|
17
|
+
expect(createConfig({ deployAdapter: cloudflare }).deployAdapter).toBe(cloudflare);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -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,5 +1,147 @@
|
|
|
1
|
-
import { describe, expect, test } from 'vitest';
|
|
2
|
-
import {
|
|
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', () => {
|
|
@@ -14,7 +156,7 @@ describe('extractExports', () => {
|
|
|
14
156
|
});
|
|
15
157
|
});
|
|
16
158
|
|
|
17
|
-
test('returns
|
|
159
|
+
test('returns namespace export when none found', () => {
|
|
18
160
|
const contents = 'function noop(){return 1}const value=2;';
|
|
19
161
|
|
|
20
162
|
const result = extractExports(contents);
|
|
@@ -25,3 +167,14 @@ describe('extractExports', () => {
|
|
|
25
167
|
});
|
|
26
168
|
});
|
|
27
169
|
});
|
|
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
|
+
});
|
package/src/client/js.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join, relative } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
1
4
|
import { html } from '@hyperspan/html';
|
|
5
|
+
import { assetHash as assetHashFn } from '../utils';
|
|
2
6
|
import { getImportMap, registerImport, resolveImport } from './manifest';
|
|
3
7
|
import type { Hyperspan as HS } from '../types';
|
|
4
8
|
|
|
@@ -7,6 +11,65 @@ export { registerImport, resolveImport, getImportMap } from './manifest';
|
|
|
7
11
|
export const JS_PUBLIC_PATH = '/_hs/js';
|
|
8
12
|
export const JS_ISLAND_PUBLIC_PATH = '/_hs/js/islands';
|
|
9
13
|
|
|
14
|
+
export type ClientJSType = 'module' | 'iife';
|
|
15
|
+
|
|
16
|
+
export type BuildClientJSOptions = {
|
|
17
|
+
/** `iife` emits a classic <script> (runs during HTML streaming). Default `module`. */
|
|
18
|
+
type?: ClientJSType;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ClientJSEntry = {
|
|
22
|
+
/** Original path/specifier passed to `buildClientJS`. */
|
|
23
|
+
modulePath: string;
|
|
24
|
+
absPath: string;
|
|
25
|
+
assetHash: string;
|
|
26
|
+
esmName: string;
|
|
27
|
+
publicPath: string;
|
|
28
|
+
exports: string;
|
|
29
|
+
fnArgs: string;
|
|
30
|
+
type: ClientJSType;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const CLIENT_JS_REGISTRY = Symbol.for('@hyperspan/client-js-entries');
|
|
34
|
+
const PATH_ALIASES = Symbol.for('@hyperspan/path-aliases');
|
|
35
|
+
|
|
36
|
+
function getPathAliases(): Record<string, string> {
|
|
37
|
+
const globalAliases = globalThis as {
|
|
38
|
+
[PATH_ALIASES]?: Record<string, string>;
|
|
39
|
+
};
|
|
40
|
+
if (!globalAliases[PATH_ALIASES]) {
|
|
41
|
+
globalAliases[PATH_ALIASES] = {};
|
|
42
|
+
}
|
|
43
|
+
return globalAliases[PATH_ALIASES];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Register tsconfig path aliases (`~/` → project root). Called by the Vite plugin. */
|
|
47
|
+
export function registerPathAliases(aliases: Record<string, string>): void {
|
|
48
|
+
Object.assign(getPathAliases(), aliases);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveWithAliases(specifier: string): string | undefined {
|
|
52
|
+
const aliases = getPathAliases();
|
|
53
|
+
const keys = Object.keys(aliases).sort((a, b) => b.length - a.length);
|
|
54
|
+
for (const key of keys) {
|
|
55
|
+
if (specifier === key || specifier.startsWith(key)) {
|
|
56
|
+
const rest = specifier.slice(key.length).replace(/^\//, '');
|
|
57
|
+
return join(aliases[key], rest);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getClientJSRegistry(): Map<string, ClientJSEntry> {
|
|
64
|
+
const globalRegistry = globalThis as {
|
|
65
|
+
[CLIENT_JS_REGISTRY]?: Map<string, ClientJSEntry>;
|
|
66
|
+
};
|
|
67
|
+
if (!globalRegistry[CLIENT_JS_REGISTRY]) {
|
|
68
|
+
globalRegistry[CLIENT_JS_REGISTRY] = new Map();
|
|
69
|
+
}
|
|
70
|
+
return globalRegistry[CLIENT_JS_REGISTRY];
|
|
71
|
+
}
|
|
72
|
+
|
|
10
73
|
/**
|
|
11
74
|
* Backward-compatible import map (backed by asset manifest).
|
|
12
75
|
*/
|
|
@@ -35,40 +98,225 @@ export const JS_IMPORT_MAP = {
|
|
|
35
98
|
},
|
|
36
99
|
};
|
|
37
100
|
|
|
38
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Prefer a cwd-relative hash so `import.meta.resolve(...)` and `app/client/foo.ts`
|
|
103
|
+
* produce the same public URL. Absolute paths outside the project stay as-is.
|
|
104
|
+
*/
|
|
105
|
+
function stableHashKey(absPath: string): string {
|
|
106
|
+
const normalized = absPath.replace(/\\/g, '/');
|
|
107
|
+
if (typeof process !== 'undefined' && typeof process.cwd === 'function') {
|
|
108
|
+
const rel = relative(process.cwd(), absPath).replace(/\\/g, '/');
|
|
109
|
+
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
|
|
110
|
+
return rel;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const marker = '/node_modules/';
|
|
114
|
+
const idx = normalized.lastIndexOf(marker);
|
|
115
|
+
if (idx !== -1) {
|
|
116
|
+
return normalized.slice(idx + marker.length);
|
|
117
|
+
}
|
|
118
|
+
return normalized;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function toFilePath(modulePath: string): string {
|
|
122
|
+
return modulePath.startsWith('file://') ? fileURLToPath(modulePath) : modulePath;
|
|
123
|
+
}
|
|
39
124
|
|
|
40
125
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
126
|
+
* `import.meta.resolve('./file.ts')` → file URL or absolute path.
|
|
127
|
+
* `~/app/client/foo.ts` → registered tsconfig alias.
|
|
128
|
+
* `app/client/foo.ts` → project root.
|
|
129
|
+
* Relative `./` / `../` paths error — resolve them at the call site.
|
|
43
130
|
*/
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
: _modulePathResolved.includes('actions')
|
|
49
|
-
? 'actions'
|
|
50
|
-
: 'scripts';
|
|
51
|
-
|
|
52
|
-
try {
|
|
53
|
-
return getClientJSFromManifest(moduleId);
|
|
54
|
-
} catch {
|
|
55
|
-
const esmName = moduleId;
|
|
56
|
-
const publicPath = `${JS_PUBLIC_PATH}/hyperspan-${moduleId}.client.js`;
|
|
57
|
-
registerImport(esmName, publicPath);
|
|
58
|
-
return {
|
|
59
|
-
assetHash: moduleId,
|
|
60
|
-
esmName,
|
|
61
|
-
publicPath,
|
|
62
|
-
renderScriptTag: () => html`<script type="module" src="${publicPath}"></script>`,
|
|
63
|
-
};
|
|
131
|
+
function resolveClientModule(modulePath: string): { hashKey: string; absPath: string } {
|
|
132
|
+
if (modulePath.startsWith('file://') || isAbsolute(modulePath)) {
|
|
133
|
+
const absPath = toFilePath(modulePath);
|
|
134
|
+
return { hashKey: stableHashKey(absPath), absPath };
|
|
64
135
|
}
|
|
136
|
+
|
|
137
|
+
const specifier = modulePath.replace(/\\/g, '/');
|
|
138
|
+
if (specifier.startsWith('.')) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`[Hyperspan] buildClientJS(${JSON.stringify(modulePath)}) got a relative path. ` +
|
|
141
|
+
`Use import.meta.resolve(${JSON.stringify(modulePath)}) at the call site, ` +
|
|
142
|
+
`or a tsconfig alias like '~/app/client/file.ts'.`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const aliased = resolveWithAliases(specifier);
|
|
147
|
+
if (aliased) {
|
|
148
|
+
return { hashKey: specifier, absPath: aliased };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { hashKey: specifier, absPath: join(process.cwd(), specifier) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function getClientJSEntries(): ClientJSEntry[] {
|
|
155
|
+
return [...getClientJSRegistry().values()];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function getClientJSEntryByEsmName(esmName: string): ClientJSEntry | undefined {
|
|
159
|
+
return getClientJSRegistry().get(esmName);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function resetClientJSEntriesForTests(): void {
|
|
163
|
+
getClientJSRegistry().clear();
|
|
164
|
+
const aliases = getPathAliases();
|
|
165
|
+
for (const key of Object.keys(aliases)) {
|
|
166
|
+
delete aliases[key];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function registerClientJS(
|
|
171
|
+
modulePathResolved: string,
|
|
172
|
+
options: BuildClientJSOptions = {}
|
|
173
|
+
): HS.ClientJSBuildResult {
|
|
174
|
+
const type = options.type ?? 'module';
|
|
175
|
+
const { hashKey, absPath } = resolveClientModule(modulePathResolved);
|
|
176
|
+
const hash = assetHashFn(hashKey);
|
|
177
|
+
const esmName = `client-${hash}`;
|
|
178
|
+
const publicPath = resolveImport(esmName) ?? `${JS_PUBLIC_PATH}/${esmName}.js`;
|
|
179
|
+
|
|
180
|
+
let exports = '* as _module';
|
|
181
|
+
let fnArgs = '_module';
|
|
182
|
+
const sourcePath = existsSync(absPath) ? absPath : null;
|
|
183
|
+
if (sourcePath) {
|
|
184
|
+
const source = readFileSync(sourcePath, 'utf-8');
|
|
185
|
+
const discovered = discoverClientExports(source);
|
|
186
|
+
exports = discovered.exports;
|
|
187
|
+
fnArgs = discovered.fnArgs;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
getClientJSRegistry().set(esmName, {
|
|
191
|
+
modulePath: modulePathResolved,
|
|
192
|
+
absPath,
|
|
193
|
+
assetHash: hash,
|
|
194
|
+
esmName,
|
|
195
|
+
publicPath,
|
|
196
|
+
exports,
|
|
197
|
+
fnArgs,
|
|
198
|
+
type,
|
|
199
|
+
});
|
|
200
|
+
registerImport(esmName, publicPath);
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
assetHash: hash,
|
|
204
|
+
esmName,
|
|
205
|
+
publicPath,
|
|
206
|
+
renderScriptTag: (loadScript) => {
|
|
207
|
+
if (type === 'iife') {
|
|
208
|
+
return html`<script src="${publicPath}"></script>`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const t = typeof loadScript;
|
|
212
|
+
|
|
213
|
+
if (t === 'string') {
|
|
214
|
+
return html`
|
|
215
|
+
<script type="module" data-source-id="${hash}">
|
|
216
|
+
import ${exports} from '${esmName}';
|
|
217
|
+
(${html.raw(loadScript as string)})(${fnArgs});
|
|
218
|
+
</script>
|
|
219
|
+
`;
|
|
220
|
+
}
|
|
221
|
+
if (t === 'function') {
|
|
222
|
+
return html`
|
|
223
|
+
<script type="module" data-source-id="${hash}">
|
|
224
|
+
import ${exports} from '${esmName}';
|
|
225
|
+
(${html.raw(functionToString(loadScript))})(${fnArgs});
|
|
226
|
+
</script>
|
|
227
|
+
`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return html`
|
|
231
|
+
<script type="module" data-source-id="${hash}">
|
|
232
|
+
import '${esmName}';
|
|
233
|
+
</script>
|
|
234
|
+
`;
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Register a client JS module for Vite to bundle.
|
|
241
|
+
* Prefer a tsconfig alias or project-root path (`~/app/client/foo.ts`,
|
|
242
|
+
* `app/client/foo.ts`). Relative `./` / `../` paths must be resolved at the
|
|
243
|
+
* call site with `import.meta.resolve('./file.ts')`.
|
|
244
|
+
*/
|
|
245
|
+
export async function buildClientJS(
|
|
246
|
+
modulePathResolved: string,
|
|
247
|
+
options: BuildClientJSOptions = {}
|
|
248
|
+
): Promise<HS.ClientJSBuildResult> {
|
|
249
|
+
return registerClientJS(modulePathResolved, options);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export const streamingClient = registerClientJS(
|
|
253
|
+
import.meta.resolve('./_hs/hyperspan-streaming.client.ts'),
|
|
254
|
+
{ type: 'iife' }
|
|
255
|
+
);
|
|
256
|
+
export const actionsClient = registerClientJS(
|
|
257
|
+
import.meta.resolve('./_hs/hyperspan-actions.client.ts')
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Discover export names from source or bundled client JS.
|
|
262
|
+
* Prefers bundled `export{...}` form, then common source export patterns.
|
|
263
|
+
*/
|
|
264
|
+
export function discoverClientExports(contents: string): { exports: string; fnArgs: string } {
|
|
265
|
+
const bundled = extractExports(contents);
|
|
266
|
+
if (bundled.exports !== '* as _module') {
|
|
267
|
+
return bundled;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const names: string[] = [];
|
|
271
|
+
const seen = new Set<string>();
|
|
272
|
+
|
|
273
|
+
for (const re of [
|
|
274
|
+
/export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g,
|
|
275
|
+
/export\s+const\s+([A-Za-z_$][\w$]*)/g,
|
|
276
|
+
/export\s+class\s+([A-Za-z_$][\w$]*)/g,
|
|
277
|
+
/export\s+let\s+([A-Za-z_$][\w$]*)/g,
|
|
278
|
+
]) {
|
|
279
|
+
let match: RegExpExecArray | null;
|
|
280
|
+
while ((match = re.exec(contents)) !== null) {
|
|
281
|
+
if (!seen.has(match[1])) {
|
|
282
|
+
names.push(match[1]);
|
|
283
|
+
seen.add(match[1]);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const exportBlock = /export\s*\{([^}]+)\}/g;
|
|
289
|
+
let block: RegExpExecArray | null;
|
|
290
|
+
while ((block = exportBlock.exec(contents)) !== null) {
|
|
291
|
+
for (const part of block[1].split(',')) {
|
|
292
|
+
const trimmed = part.trim();
|
|
293
|
+
if (!trimmed || trimmed.startsWith('type ')) continue;
|
|
294
|
+
const asMatch = trimmed.match(/^(?:([\w$]+)\s+as\s+)?([\w$]+)$/);
|
|
295
|
+
const name = asMatch?.[2] ?? trimmed;
|
|
296
|
+
if (name && name !== 'default' && !seen.has(name)) {
|
|
297
|
+
names.push(name);
|
|
298
|
+
seen.add(name);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (names.length === 0) {
|
|
304
|
+
if (/export\s+default\b/.test(contents)) {
|
|
305
|
+
return { exports: 'DefaultExport', fnArgs: 'DefaultExport' };
|
|
306
|
+
}
|
|
307
|
+
return { exports: '* as _module', fnArgs: '_module' };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const exports = `{${names.join(', ')}}`;
|
|
311
|
+
return { exports, fnArgs: exports };
|
|
65
312
|
}
|
|
66
313
|
|
|
67
314
|
/**
|
|
68
|
-
* Extract the exports from a client JS module
|
|
315
|
+
* Extract the exports from a client JS module (bundled `export{a as b}` form).
|
|
69
316
|
*/
|
|
70
317
|
export function extractExports(contents: string): { exports: string; fnArgs: string } {
|
|
71
|
-
const
|
|
318
|
+
const exportRegex = /export\{(.*)\}/g;
|
|
319
|
+
const exportLine = exportRegex.exec(contents);
|
|
72
320
|
let exports = '{}';
|
|
73
321
|
let fnArgs = '{}';
|
|
74
322
|
|
package/src/client/manifest.ts
CHANGED
|
@@ -3,11 +3,7 @@ import type { Hyperspan as HS } from '../types';
|
|
|
3
3
|
export type AssetManifest = {
|
|
4
4
|
imports: Record<string, string>;
|
|
5
5
|
css: Record<string, string[]>;
|
|
6
|
-
clients:
|
|
7
|
-
streaming?: string;
|
|
8
|
-
actions?: string;
|
|
9
|
-
scripts?: string;
|
|
10
|
-
};
|
|
6
|
+
clients: Record<string, string>;
|
|
11
7
|
};
|
|
12
8
|
|
|
13
9
|
let _manifest: AssetManifest = {
|
|
@@ -67,7 +63,7 @@ export function getClientJSFromManifest(
|
|
|
67
63
|
moduleId: string,
|
|
68
64
|
_exportNames = 'default'
|
|
69
65
|
): ClientJSBuildResult {
|
|
70
|
-
const publicPath = _manifest.clients[moduleId
|
|
66
|
+
const publicPath = _manifest.clients[moduleId];
|
|
71
67
|
if (!publicPath) {
|
|
72
68
|
throw new Error(
|
|
73
69
|
`[Hyperspan] Client module "${moduleId}" not found in asset manifest. Run hyperspan build or start dev server.`
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Dev-only platform bindings (D1, KV, etc.) shared across Vite SSR modules. */
|
|
2
|
+
let devBindings: unknown;
|
|
3
|
+
|
|
4
|
+
export function setDevBindings(env: unknown): void {
|
|
5
|
+
devBindings = env;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getDevBindings(): unknown {
|
|
9
|
+
return devBindings;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function clearDevBindingsForTests(): void {
|
|
13
|
+
devBindings = undefined;
|
|
14
|
+
}
|
|
@@ -73,6 +73,29 @@ describe('createFetchHandler', () => {
|
|
|
73
73
|
expect(await response.text()).toBe('abc');
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
+
test('preserves Cookie on the request passed to the route', async () => {
|
|
77
|
+
const server = await createServer({
|
|
78
|
+
appDir: './app',
|
|
79
|
+
publicDir: './public',
|
|
80
|
+
plugins: [],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const route = createRoute({ path: '/session' }).get((c: HS.Context) =>
|
|
84
|
+
c.res.text(c.req.raw.headers.get('cookie') ?? '')
|
|
85
|
+
);
|
|
86
|
+
server._routes.push(route);
|
|
87
|
+
|
|
88
|
+
const headers = new Headers({ cookie: 'session=abc' });
|
|
89
|
+
const request = new Request('http://localhost/session', { headers });
|
|
90
|
+
// Node's Request constructor strips Cookie; re-attach like Vite's nodeToWebRequest.
|
|
91
|
+
Object.defineProperty(request, 'headers', { value: headers });
|
|
92
|
+
|
|
93
|
+
const fetch = createFetchHandler(server);
|
|
94
|
+
const response = await fetch(request);
|
|
95
|
+
|
|
96
|
+
expect(await response.text()).toBe('session=abc');
|
|
97
|
+
});
|
|
98
|
+
|
|
76
99
|
test('redirects trailing slashes', async () => {
|
|
77
100
|
const server = await createServer({
|
|
78
101
|
appDir: './app',
|
package/src/fetch-handler.ts
CHANGED
|
@@ -141,10 +141,11 @@ export function createFetchHandler(
|
|
|
141
141
|
|
|
142
142
|
const matched = matchRoute(compiledRoutes, pathname, method);
|
|
143
143
|
if (matched) {
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
// Attach params on the original request. `new Request(request)` clones
|
|
145
|
+
// through Node's Request constructor, which strips Cookie/Origin/Host.
|
|
146
|
+
(request as Request & { params?: Record<string, string | undefined> }).params =
|
|
146
147
|
matched.params;
|
|
147
|
-
return matched.route.fetch(
|
|
148
|
+
return matched.route.fetch(request);
|
|
148
149
|
}
|
|
149
150
|
|
|
150
151
|
if (options.onNotMatched) {
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export {
|
|
|
3
3
|
createContext,
|
|
4
4
|
createRoute,
|
|
5
5
|
createServer,
|
|
6
|
+
initServerRoutes,
|
|
6
7
|
getRunnableRoute,
|
|
7
8
|
StreamResponse,
|
|
8
9
|
IS_PROD,
|
|
@@ -19,5 +20,21 @@ export {
|
|
|
19
20
|
getImportMap,
|
|
20
21
|
registerImport,
|
|
21
22
|
} from './client/manifest';
|
|
23
|
+
export { buildClientJS, registerPathAliases } from './client/js';
|
|
24
|
+
export type { BuildClientJSOptions, ClientJSType } from './client/js';
|
|
25
|
+
export { registerRouteModule, registerRouteModules } from './register-routes';
|
|
26
|
+
export type { RouteModuleEntry } from './register-routes';
|
|
22
27
|
export type { AssetManifest } from './client/manifest';
|
|
23
|
-
export
|
|
28
|
+
export { renderIsland } from './island';
|
|
29
|
+
export type { IslandRenderOptions } from './island';
|
|
30
|
+
export type {
|
|
31
|
+
Hyperspan,
|
|
32
|
+
ServerCreateContext,
|
|
33
|
+
DeployAdapter,
|
|
34
|
+
Adapter,
|
|
35
|
+
AdapterAfterBuildContext,
|
|
36
|
+
DeployEntry,
|
|
37
|
+
DeployEntryContext,
|
|
38
|
+
ServerEntrySlots,
|
|
39
|
+
ServerEntryTemplate,
|
|
40
|
+
} from './types';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest';
|
|
2
|
+
import { renderIsland } from './island';
|
|
3
|
+
|
|
4
|
+
describe('renderIsland', () => {
|
|
5
|
+
test('throws when component has no __HS_ISLAND', () => {
|
|
6
|
+
expect(() => renderIsland({ name: 'Bare' } as never, {})).toThrow(/not a Hyperspan island/i);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('returns html.raw for sync island render', () => {
|
|
10
|
+
const component = {
|
|
11
|
+
name: 'Counter',
|
|
12
|
+
__HS_ISLAND: {
|
|
13
|
+
render: () => '<div id="x">1</div>',
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
const result = renderIsland(component, { count: 1 });
|
|
17
|
+
expect(result).toHaveProperty('_kind', 'html_safe');
|
|
18
|
+
expect((result as { content: string }).content).toContain('<div id="x">1</div>');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('awaits async island render', async () => {
|
|
22
|
+
const component = {
|
|
23
|
+
__HS_ISLAND: {
|
|
24
|
+
render: async () => '<div>async</div>',
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
const result = await renderIsland(component, {});
|
|
28
|
+
expect(result.content).toContain('<div>async</div>');
|
|
29
|
+
});
|
|
30
|
+
});
|
package/src/island.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { html } from '@hyperspan/html';
|
|
2
|
+
|
|
3
|
+
type IslandComponent = {
|
|
4
|
+
__HS_ISLAND?: {
|
|
5
|
+
render: (props: unknown, options: unknown) => string | Promise<string>;
|
|
6
|
+
};
|
|
7
|
+
name?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type IslandRenderOptions = {
|
|
11
|
+
ssr?: boolean;
|
|
12
|
+
loading?: 'lazy' | undefined;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Render any Hyperspan island component (Preact, Svelte, Vue).
|
|
17
|
+
* Proxies to `Component.__HS_ISLAND.render` injected by the island Vite plugin.
|
|
18
|
+
*/
|
|
19
|
+
export function renderIsland(
|
|
20
|
+
Component: IslandComponent,
|
|
21
|
+
props: Record<string, unknown> = {},
|
|
22
|
+
options: IslandRenderOptions = { ssr: true }
|
|
23
|
+
) {
|
|
24
|
+
if (!Component?.__HS_ISLAND?.render) {
|
|
25
|
+
const label = Component?.name ?? 'Component';
|
|
26
|
+
throw new Error(
|
|
27
|
+
`${label} is not a Hyperspan island. Import it with \`with { island: 'preact' | 'svelte' | 'vue' }\` ` +
|
|
28
|
+
`and ensure the matching plugin is listed in hyperspan.config.ts plugins.`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const result = Component.__HS_ISLAND.render(props, options);
|
|
33
|
+
if (result != null && typeof (result as Promise<string>).then === 'function') {
|
|
34
|
+
return (result as Promise<string>).then((s) => html.raw(s));
|
|
35
|
+
}
|
|
36
|
+
return html.raw(result as string);
|
|
37
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { test, expect } from 'vitest';
|
|
2
|
+
import { render } from '@hyperspan/html';
|
|
3
|
+
import { hyperspanScriptTags } from './layout';
|
|
4
|
+
import { streamingClient } from './client/js';
|
|
5
|
+
|
|
6
|
+
test('streaming bootstrap injects a classic script so chunks swap in during parse', () => {
|
|
7
|
+
const html = render(hyperspanScriptTags());
|
|
8
|
+
expect(html).toContain("document.createElement('script')");
|
|
9
|
+
expect(html).toContain(`script.src = '${streamingClient.publicPath}'`);
|
|
10
|
+
expect(html).not.toContain("type = 'module'");
|
|
11
|
+
expect(html).not.toContain(`import('${streamingClient.publicPath}')`);
|
|
12
|
+
});
|
package/src/layout.ts
CHANGED
|
@@ -1,23 +1,20 @@
|
|
|
1
1
|
import { html } from '@hyperspan/html';
|
|
2
2
|
import { CSS_PUBLIC_PATH } from './client/css';
|
|
3
|
-
import { getImportMap, getRouteCss
|
|
3
|
+
import { getImportMap, getRouteCss } from './client/manifest';
|
|
4
|
+
import { actionsClient, streamingClient } from './client/js';
|
|
4
5
|
import type { Hyperspan as HS } from './types';
|
|
5
6
|
|
|
6
|
-
function streamingClientPath(): string {
|
|
7
|
-
return getClientJSFromManifest('streaming').publicPath;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
7
|
/**
|
|
11
|
-
* Output the importmap for the client so we can use ESModules on the client to load JS files on demand
|
|
8
|
+
* Output the importmap for the client so we can use ESModules on the client to load JS files on demand.
|
|
9
|
+
*
|
|
10
|
+
* The streaming client MUST be loaded as a classic <script>, not type=module.
|
|
11
|
+
* Browsers defer all ESM until the document finishes parsing, which would hold
|
|
12
|
+
* every streaming chunk until the whole page is done. A dynamically inserted
|
|
13
|
+
* classic script runs as soon as it downloads.
|
|
12
14
|
*/
|
|
13
15
|
export function hyperspanScriptTags() {
|
|
14
16
|
const imports = getImportMap();
|
|
15
|
-
|
|
16
|
-
try {
|
|
17
|
-
streamingPath = streamingClientPath();
|
|
18
|
-
} catch {
|
|
19
|
-
streamingPath = '/_hs/js/hyperspan-streaming.client.js';
|
|
20
|
-
}
|
|
17
|
+
const streamingPath = streamingClient.publicPath;
|
|
21
18
|
|
|
22
19
|
return html`
|
|
23
20
|
<script type="importmap">
|
|
@@ -65,10 +62,5 @@ export function hyperspanStyleTags(context: HS.Context) {
|
|
|
65
62
|
* Render the actions client script tag (used by createAction).
|
|
66
63
|
*/
|
|
67
64
|
export function hyperspanActionsScriptTag() {
|
|
68
|
-
|
|
69
|
-
const actions = getClientJSFromManifest('actions');
|
|
70
|
-
return html`<script type="module" src="${actions.publicPath}"></script>`;
|
|
71
|
-
} catch {
|
|
72
|
-
return html`<script type="module" src="/_hs/js/hyperspan-actions.client.js"></script>`;
|
|
73
|
-
}
|
|
65
|
+
return html`<script type="module" src="${actionsClient.publicPath}"></script>`;
|
|
74
66
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { getRunnableRoute } from './server';
|
|
2
|
+
import { isValidRoutePath, parsePath } from './utils';
|
|
3
|
+
import type { AssetManifest } from './client/manifest';
|
|
4
|
+
import type { Hyperspan as HS } from './types';
|
|
5
|
+
|
|
6
|
+
export type RouteModuleEntry = {
|
|
7
|
+
file: string;
|
|
8
|
+
mod: unknown;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Register a route or action module on a Hyperspan server.
|
|
13
|
+
* Shared by the CLI, Vite plugin, and the generated production server entry.
|
|
14
|
+
*/
|
|
15
|
+
export function registerRouteModule(
|
|
16
|
+
server: HS.Server,
|
|
17
|
+
relativeFilePath: string,
|
|
18
|
+
mod: unknown,
|
|
19
|
+
manifest?: AssetManifest
|
|
20
|
+
): HS.Route | null {
|
|
21
|
+
if (!isValidRoutePath(relativeFilePath)) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const route = getRunnableRoute(mod);
|
|
26
|
+
const parsedPath = parsePath(relativeFilePath);
|
|
27
|
+
|
|
28
|
+
let path = parsedPath.path;
|
|
29
|
+
if (typeof route._path === 'function') {
|
|
30
|
+
const routePath = route._path();
|
|
31
|
+
if (routePath && routePath !== '/') path = routePath;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!route._config.path) {
|
|
35
|
+
route._config.path = path;
|
|
36
|
+
if (parsedPath.params.length > 0) {
|
|
37
|
+
const params = route._config.params ?? {};
|
|
38
|
+
parsedPath.params.forEach((param) => {
|
|
39
|
+
params[param] = undefined;
|
|
40
|
+
});
|
|
41
|
+
route._config.params = params;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (manifest) {
|
|
46
|
+
const cssFiles = manifest.css?.[path] ?? manifest.css?.['*'];
|
|
47
|
+
if (cssFiles?.length) {
|
|
48
|
+
route._config.cssImports = cssFiles;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
server._routes.push(route);
|
|
53
|
+
return route;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function registerRouteModules(
|
|
57
|
+
server: HS.Server,
|
|
58
|
+
modules: RouteModuleEntry[],
|
|
59
|
+
manifest?: AssetManifest
|
|
60
|
+
): void {
|
|
61
|
+
for (const { file, mod } of modules) {
|
|
62
|
+
registerRouteModule(server, file, mod, manifest);
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/server.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { test, expect, describe } from 'vitest';
|
|
2
|
-
import { createRoute, createServer, createContext } from './server';
|
|
2
|
+
import { createRoute, createServer, createContext, initServerRoutes } from './server';
|
|
3
3
|
import { createAction } from './actions';
|
|
4
4
|
import { html, placeholder } from '@hyperspan/html';
|
|
5
5
|
import type { Hyperspan as HS } from './types';
|
|
@@ -92,6 +92,25 @@ test('server returns a route with a POST request', async () => {
|
|
|
92
92
|
expect(await response.text()).toBe('<h1>POST /users</h1>');
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
+
test('POST HTML response does not inherit request Content-Length', async () => {
|
|
96
|
+
const route = createRoute().post((context: HS.Context) => {
|
|
97
|
+
return context.res.html('<h1>hello world this is longer than the form body</h1>');
|
|
98
|
+
});
|
|
99
|
+
const body = 'email=a@b.com&password=secret';
|
|
100
|
+
const request = new Request('http://localhost:3000/', {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: {
|
|
103
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
104
|
+
'content-length': String(body.length),
|
|
105
|
+
},
|
|
106
|
+
body,
|
|
107
|
+
});
|
|
108
|
+
const response = await route.fetch(request);
|
|
109
|
+
const text = await response.text();
|
|
110
|
+
expect(text).toBe('<h1>hello world this is longer than the form body</h1>');
|
|
111
|
+
expect(Number(response.headers.get('content-length') ?? text.length)).toBe(text.length);
|
|
112
|
+
});
|
|
113
|
+
|
|
95
114
|
test('server returns a route with a ALL request', async () => {
|
|
96
115
|
const server = await createServer({
|
|
97
116
|
appDir: './app',
|
|
@@ -566,3 +585,31 @@ describe('when streaming is disabled', () => {
|
|
|
566
585
|
expect(text).not.toContain('hs:loading');
|
|
567
586
|
});
|
|
568
587
|
});
|
|
588
|
+
|
|
589
|
+
test('initServerRoutes runs beforeRoutesAdded, then addRoutes, then afterRoutesAdded', async () => {
|
|
590
|
+
const order: string[] = [];
|
|
591
|
+
const server = await createServer({
|
|
592
|
+
appDir: './app',
|
|
593
|
+
publicDir: './public',
|
|
594
|
+
plugins: [],
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
await initServerRoutes(
|
|
598
|
+
server,
|
|
599
|
+
{
|
|
600
|
+
beforeRoutesAdded(s) {
|
|
601
|
+
order.push('before');
|
|
602
|
+
s.use(async (_c, next) => next());
|
|
603
|
+
},
|
|
604
|
+
afterRoutesAdded() {
|
|
605
|
+
order.push('after');
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
() => {
|
|
609
|
+
order.push('routes');
|
|
610
|
+
}
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
expect(order).toEqual(['before', 'routes', 'after']);
|
|
614
|
+
expect(server._middleware['*']).toHaveLength(1);
|
|
615
|
+
});
|
package/src/server.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { isbot } from 'isbot';
|
|
|
12
12
|
import { executeMiddleware } from './middleware';
|
|
13
13
|
import { buildUrl, parsePath, removeUndefined } from './utils';
|
|
14
14
|
import { Cookies } from './cookies';
|
|
15
|
+
import { nodeAdapter } from '@hyperspan/adapter-node';
|
|
15
16
|
|
|
16
17
|
import type { Hyperspan as HS } from './types';
|
|
17
18
|
|
|
@@ -51,6 +52,7 @@ export function createConfig(config: Partial<HS.Config> = {}): HS.Config {
|
|
|
51
52
|
appDir: './app',
|
|
52
53
|
publicDir: './public',
|
|
53
54
|
plugins: [],
|
|
55
|
+
deployAdapter: config.deployAdapter ?? nodeAdapter(),
|
|
54
56
|
responseOptions: {
|
|
55
57
|
disableStreaming: hyperspanDisableStreaming,
|
|
56
58
|
},
|
|
@@ -58,6 +60,7 @@ export function createConfig(config: Partial<HS.Config> = {}): HS.Config {
|
|
|
58
60
|
return {
|
|
59
61
|
...defaultConfig,
|
|
60
62
|
...config,
|
|
63
|
+
deployAdapter: config.deployAdapter ?? defaultConfig.deployAdapter,
|
|
61
64
|
responseOptions: {
|
|
62
65
|
...defaultConfig.responseOptions,
|
|
63
66
|
...config.responseOptions,
|
|
@@ -72,7 +75,8 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
72
75
|
const url = new URL(req.url);
|
|
73
76
|
const query = new URLSearchParams(url.search);
|
|
74
77
|
const method = req.method.toUpperCase();
|
|
75
|
-
const
|
|
78
|
+
const requestHeaders = new Headers(req.headers);
|
|
79
|
+
const responseHeaders = new Headers();
|
|
76
80
|
const path = route?._path() || '/';
|
|
77
81
|
const requestParams = (req as Request & { params?: Record<string, string | undefined> }).params;
|
|
78
82
|
const params: Record<string, string | undefined> = Object.assign(
|
|
@@ -92,12 +96,21 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
92
96
|
// Status override for the response. Will use if set. (e.g. c.res.status = 400)
|
|
93
97
|
let status: number | undefined = undefined;
|
|
94
98
|
|
|
99
|
+
const copyHeaders = (source: Headers, target: Headers) => {
|
|
100
|
+
source.forEach((value, key) => {
|
|
101
|
+
if (key.toLowerCase() === 'set-cookie') return;
|
|
102
|
+
target.set(key, value);
|
|
103
|
+
});
|
|
104
|
+
const cookies = typeof source.getSetCookie === 'function' ? source.getSetCookie() : [];
|
|
105
|
+
for (const cookie of cookies) {
|
|
106
|
+
target.append('Set-Cookie', cookie);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
95
110
|
const merge = async (response: Response) => {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
...Object.fromEntries(response.headers.entries()),
|
|
100
|
-
};
|
|
111
|
+
const mergedHeaders = new Headers();
|
|
112
|
+
copyHeaders(responseHeaders, mergedHeaders);
|
|
113
|
+
copyHeaders(response.headers, mergedHeaders);
|
|
101
114
|
|
|
102
115
|
return new Response(await response.text(), {
|
|
103
116
|
status: context.res.status ?? response.status,
|
|
@@ -121,7 +134,7 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
121
134
|
raw: req,
|
|
122
135
|
url,
|
|
123
136
|
method,
|
|
124
|
-
headers,
|
|
137
|
+
headers: requestHeaders,
|
|
125
138
|
query,
|
|
126
139
|
cookies: new Cookies(req),
|
|
127
140
|
async text() {
|
|
@@ -138,8 +151,8 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
138
151
|
},
|
|
139
152
|
},
|
|
140
153
|
res: {
|
|
141
|
-
cookies: new Cookies(req,
|
|
142
|
-
headers,
|
|
154
|
+
cookies: new Cookies(req, responseHeaders),
|
|
155
|
+
headers: responseHeaders,
|
|
143
156
|
status,
|
|
144
157
|
html: (html: string, options?: ResponseInit) =>
|
|
145
158
|
merge(
|
|
@@ -501,6 +514,20 @@ export async function createServer(config: HS.Config = {} as HS.Config): Promise
|
|
|
501
514
|
return api;
|
|
502
515
|
}
|
|
503
516
|
|
|
517
|
+
/**
|
|
518
|
+
* Shared route lifecycle for Vite dev, production entries, and `hyperspan start`.
|
|
519
|
+
* Always: `beforeRoutesAdded` → add routes → `afterRoutesAdded`.
|
|
520
|
+
*/
|
|
521
|
+
export async function initServerRoutes(
|
|
522
|
+
server: HS.Server,
|
|
523
|
+
config: Pick<HS.Config, 'beforeRoutesAdded' | 'afterRoutesAdded'>,
|
|
524
|
+
addRoutes: (server: HS.Server) => void | Promise<void>
|
|
525
|
+
): Promise<void> {
|
|
526
|
+
await config.beforeRoutesAdded?.(server);
|
|
527
|
+
await addRoutes(server);
|
|
528
|
+
await config.afterRoutesAdded?.(server);
|
|
529
|
+
}
|
|
530
|
+
|
|
504
531
|
/**
|
|
505
532
|
* Checks if a response is HTML content
|
|
506
533
|
*/
|
|
@@ -541,7 +568,7 @@ export async function returnHTMLResponse(
|
|
|
541
568
|
if (!disableStreaming && (routeContent as HSHtml).asyncContent?.length > 0) {
|
|
542
569
|
return new StreamResponse(
|
|
543
570
|
renderStream(routeContent as HSHtml, {
|
|
544
|
-
renderChunk: (chunk) => {
|
|
571
|
+
renderChunk: (chunk: { id: string; content: string }) => {
|
|
545
572
|
// Trailing <!--/hs:chunk--> marks the end of a streaming chunk boundary.
|
|
546
573
|
return html`
|
|
547
574
|
<template id="${chunk.id}_content">${html.raw(chunk.content)}<!--end--></template>
|
package/src/types.ts
CHANGED
|
@@ -63,13 +63,78 @@ export namespace Hyperspan {
|
|
|
63
63
|
disableStreaming?: DisableStreamingFn;
|
|
64
64
|
};
|
|
65
65
|
|
|
66
|
+
export type AdapterAfterBuildContext = {
|
|
67
|
+
root: string;
|
|
68
|
+
appDir: string;
|
|
69
|
+
outDir: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type ServerCreateContext = {
|
|
73
|
+
env: unknown;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type DeployEntryContext = {
|
|
77
|
+
createHyperspanServer: (ctx: ServerCreateContext) => Promise<Server>;
|
|
78
|
+
config: Config;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type DeployEntry = {
|
|
82
|
+
start?(options?: { port?: number }): unknown | Promise<unknown>;
|
|
83
|
+
fetch?(request: Request, env?: unknown, ctx?: unknown): Response | Promise<Response>;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Optional snippets interpolated into generated `dist/server.ts`. Null/omitted slots emit nothing. */
|
|
87
|
+
export type ServerEntrySlots = {
|
|
88
|
+
beforeFileContent?: string | null;
|
|
89
|
+
beforeCreateServer?: string | null;
|
|
90
|
+
afterCreateServer?: string | null;
|
|
91
|
+
beforeRoutes?: string | null;
|
|
92
|
+
afterRoutes?: string | null;
|
|
93
|
+
afterFileContent?: string | null;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export type ServerEntryTemplate = (slots?: ServerEntrySlots) => string;
|
|
97
|
+
|
|
98
|
+
export type Adapter = {
|
|
99
|
+
/** Diagnostic name, e.g. `node`, `cloudflare`. */
|
|
100
|
+
name: string;
|
|
101
|
+
/**
|
|
102
|
+
* Native ESM specifier exporting `resolveDevEnv(root)`.
|
|
103
|
+
* Imported by the Vite plugin so platform SDKs like wrangler load correctly.
|
|
104
|
+
*/
|
|
105
|
+
devModule?: string;
|
|
106
|
+
resolveDevEnv?(root: string): Promise<unknown>;
|
|
107
|
+
/**
|
|
108
|
+
* Render generated `dist/server.ts` from the shared bootstrap template.
|
|
109
|
+
* Fill slots (especially `afterFileContent`) for the platform entry.
|
|
110
|
+
*/
|
|
111
|
+
renderServerEntry(template: ServerEntryTemplate): string | Promise<string>;
|
|
112
|
+
afterBuild?(ctx: AdapterAfterBuildContext): void | Promise<void>;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** Runtime adapter object (`nodeAdapter()`, `cloudflareAdapter()`, …). */
|
|
116
|
+
export type DeployAdapter = Adapter;
|
|
117
|
+
|
|
66
118
|
export type Config = {
|
|
67
119
|
appDir: string;
|
|
68
120
|
publicDir: string;
|
|
69
|
-
|
|
121
|
+
/** Island plugins (`preactPlugin()`, `sveltePlugin()`, …). */
|
|
122
|
+
plugins: Array<Hyperspan.Plugin>;
|
|
123
|
+
/**
|
|
124
|
+
* Deployment adapter. Pass `cloudflareAdapter()` or `bunAdapter()`.
|
|
125
|
+
* Omit (or pass `nodeAdapter()`) to deploy to Node.
|
|
126
|
+
*/
|
|
127
|
+
deployAdapter: Adapter;
|
|
128
|
+
/**
|
|
129
|
+
* Called before the server is created, with platform bindings.
|
|
130
|
+
* - Vite/`hyperspan dev`: adapter `devModule` `resolveDevEnv(root)` when present; else `process.env`
|
|
131
|
+
* - Node/Bun production entry: `process.env`
|
|
132
|
+
* - Cloudflare Workers entry: Worker `env` bindings
|
|
133
|
+
*/
|
|
134
|
+
beforeServerCreate?: (ctx: ServerCreateContext) => void | Promise<void>;
|
|
70
135
|
// For customizing the routes and adding your own...
|
|
71
|
-
beforeRoutesAdded?: (server: Hyperspan.Server) => void
|
|
72
|
-
afterRoutesAdded?: (server: Hyperspan.Server) => void
|
|
136
|
+
beforeRoutesAdded?: (server: Hyperspan.Server) => void | Promise<void>;
|
|
137
|
+
afterRoutesAdded?: (server: Hyperspan.Server) => void | Promise<void>;
|
|
73
138
|
responseOptions?: ResponseOptions;
|
|
74
139
|
};
|
|
75
140
|
|
|
@@ -387,6 +452,15 @@ export namespace Hyperspan {
|
|
|
387
452
|
}
|
|
388
453
|
}
|
|
389
454
|
|
|
455
|
+
export type DeployAdapter = Hyperspan.DeployAdapter;
|
|
456
|
+
export type ServerCreateContext = Hyperspan.ServerCreateContext;
|
|
457
|
+
export type Adapter = Hyperspan.Adapter;
|
|
458
|
+
export type AdapterAfterBuildContext = Hyperspan.AdapterAfterBuildContext;
|
|
459
|
+
export type DeployEntry = Hyperspan.DeployEntry;
|
|
460
|
+
export type DeployEntryContext = Hyperspan.DeployEntryContext;
|
|
461
|
+
export type ServerEntrySlots = Hyperspan.ServerEntrySlots;
|
|
462
|
+
export type ServerEntryTemplate = Hyperspan.ServerEntryTemplate;
|
|
463
|
+
|
|
390
464
|
declare global {
|
|
391
465
|
interface DocumentEventMap {
|
|
392
466
|
'hs:action:before-fetch': CustomEvent<Hyperspan.ActionFetchDetail>;
|
package/tsconfig.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"compileOnSave": true,
|
|
3
|
+
"include": ["src/**/*.ts"],
|
|
3
4
|
"compilerOptions": {
|
|
4
5
|
"outDir": "dist",
|
|
5
6
|
"target": "ES2020",
|
|
@@ -10,7 +11,6 @@
|
|
|
10
11
|
"esModuleInterop": true,
|
|
11
12
|
"allowImportingTsExtensions": true,
|
|
12
13
|
"noEmit": true,
|
|
13
|
-
"composite": true,
|
|
14
14
|
"strict": true,
|
|
15
15
|
"declaration": true,
|
|
16
16
|
"sourceMap": true,
|
|
@@ -19,11 +19,7 @@
|
|
|
19
19
|
"allowSyntheticDefaultImports": true,
|
|
20
20
|
"forceConsistentCasingInFileNames": true,
|
|
21
21
|
"allowJs": true,
|
|
22
|
-
"baseUrl": "."
|
|
23
|
-
"paths": {
|
|
24
|
-
"@hyperspan/html": ["../html/src/html.ts"]
|
|
25
|
-
}
|
|
22
|
+
"baseUrl": "."
|
|
26
23
|
},
|
|
27
|
-
"
|
|
28
|
-
"exclude": ["node_modules", "__tests__", "*.test.ts"]
|
|
24
|
+
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
|
29
25
|
}
|