@hyperspan/framework 1.0.29 → 2.0.0-alpha.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +30 -7
- package/src/actions.test.ts +1 -1
- package/src/actions.ts +3 -6
- package/src/adapter.test.ts +23 -0
- package/src/client/_hs/hyperspan-actions.client.ts +37 -45
- package/src/client/js.test.ts +157 -4
- package/src/client/js.ts +287 -64
- package/src/client/manifest.ts +83 -0
- package/src/dev-bindings.ts +14 -0
- package/src/fetch-handler.test.ts +113 -0
- package/src/fetch-handler.ts +168 -0
- package/src/index.ts +27 -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 +28 -11
- package/src/register-routes.ts +64 -0
- package/src/server.test.ts +60 -2
- package/src/server.ts +42 -11
- package/src/ssr/mock-dom.test.ts +1 -1
- package/src/types.ts +72 -3
- package/src/utils.test.ts +72 -2
- package/src/utils.ts +66 -10
- package/tsconfig.json +4 -8
package/src/client/js.ts
CHANGED
|
@@ -1,98 +1,322 @@
|
|
|
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';
|
|
2
5
|
import { assetHash as assetHashFn } from '../utils';
|
|
3
|
-
import {
|
|
6
|
+
import { getImportMap, registerImport, resolveImport } from './manifest';
|
|
4
7
|
import type { Hyperspan as HS } from '../types';
|
|
5
8
|
|
|
6
|
-
|
|
7
|
-
const IS_PROD = process.env.NODE_ENV === 'production';
|
|
9
|
+
export { registerImport, resolveImport, getImportMap } from './manifest';
|
|
8
10
|
|
|
9
11
|
export const JS_PUBLIC_PATH = '/_hs/js';
|
|
10
12
|
export const JS_ISLAND_PUBLIC_PATH = '/_hs/js/islands';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
+
}
|
|
15
72
|
|
|
16
73
|
/**
|
|
17
|
-
*
|
|
74
|
+
* Backward-compatible import map (backed by asset manifest).
|
|
18
75
|
*/
|
|
19
|
-
export
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
}
|
|
76
|
+
export const JS_IMPORT_MAP = {
|
|
77
|
+
get size() {
|
|
78
|
+
return Object.keys(getImportMap()).length;
|
|
79
|
+
},
|
|
80
|
+
keys() {
|
|
81
|
+
return Object.keys(getImportMap()).values();
|
|
82
|
+
},
|
|
83
|
+
has(key: string) {
|
|
84
|
+
return resolveImport(key) !== undefined;
|
|
85
|
+
},
|
|
86
|
+
get(key: string) {
|
|
87
|
+
return resolveImport(key);
|
|
88
|
+
},
|
|
89
|
+
set(key: string, value: string) {
|
|
90
|
+
registerImport(key, value);
|
|
91
|
+
return this;
|
|
92
|
+
},
|
|
93
|
+
entries() {
|
|
94
|
+
return Object.entries(getImportMap()).values();
|
|
95
|
+
},
|
|
96
|
+
[Symbol.iterator]() {
|
|
97
|
+
return Object.entries(getImportMap())[Symbol.iterator]();
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
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;
|
|
61
111
|
}
|
|
62
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
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
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.
|
|
130
|
+
*/
|
|
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 };
|
|
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
|
+
}
|
|
63
189
|
|
|
64
|
-
|
|
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);
|
|
65
201
|
|
|
66
202
|
return {
|
|
67
|
-
assetHash,
|
|
203
|
+
assetHash: hash,
|
|
68
204
|
esmName,
|
|
69
205
|
publicPath,
|
|
70
206
|
renderScriptTag: (loadScript) => {
|
|
207
|
+
if (type === 'iife') {
|
|
208
|
+
return html`<script src="${publicPath}"></script>`;
|
|
209
|
+
}
|
|
210
|
+
|
|
71
211
|
const t = typeof loadScript;
|
|
72
212
|
|
|
73
213
|
if (t === 'string') {
|
|
74
214
|
return html`
|
|
75
|
-
<script type="module" data-source-id="${
|
|
215
|
+
<script type="module" data-source-id="${hash}">
|
|
216
|
+
import ${exports} from '${esmName}';
|
|
217
|
+
(${html.raw(loadScript as string)})(${fnArgs});
|
|
218
|
+
</script>
|
|
76
219
|
`;
|
|
77
220
|
}
|
|
78
221
|
if (t === 'function') {
|
|
79
222
|
return html`
|
|
80
|
-
<script type="module" data-source-id="${
|
|
223
|
+
<script type="module" data-source-id="${hash}">
|
|
224
|
+
import ${exports} from '${esmName}';
|
|
225
|
+
(${html.raw(functionToString(loadScript))})(${fnArgs});
|
|
226
|
+
</script>
|
|
81
227
|
`;
|
|
82
228
|
}
|
|
83
229
|
|
|
84
230
|
return html`
|
|
85
|
-
<script type="module" data-source-id="${
|
|
231
|
+
<script type="module" data-source-id="${hash}">
|
|
232
|
+
import '${esmName}';
|
|
233
|
+
</script>
|
|
86
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
|
+
}
|
|
87
285
|
}
|
|
88
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 };
|
|
89
312
|
}
|
|
90
313
|
|
|
91
314
|
/**
|
|
92
|
-
* Extract the exports from a client JS module
|
|
315
|
+
* Extract the exports from a client JS module (bundled `export{a as b}` form).
|
|
93
316
|
*/
|
|
94
|
-
export function extractExports(contents: string): { exports: string
|
|
95
|
-
const
|
|
317
|
+
export function extractExports(contents: string): { exports: string; fnArgs: string } {
|
|
318
|
+
const exportRegex = /export\{(.*)\}/g;
|
|
319
|
+
const exportLine = exportRegex.exec(contents);
|
|
96
320
|
let exports = '{}';
|
|
97
321
|
let fnArgs = '{}';
|
|
98
322
|
|
|
@@ -111,8 +335,8 @@ export function extractExports(contents: string): { exports: string, fnArgs: str
|
|
|
111
335
|
fnArgs = exports.replace(/(\w+)\s*as\s*(\w+)/g, '$1: $2').trim();
|
|
112
336
|
|
|
113
337
|
if (exports === '{}' && fnArgs === '{}') {
|
|
114
|
-
exports = '* as _module'
|
|
115
|
-
fnArgs = '_module'
|
|
338
|
+
exports = '* as _module';
|
|
339
|
+
fnArgs = '_module';
|
|
116
340
|
}
|
|
117
341
|
|
|
118
342
|
return { exports, fnArgs };
|
|
@@ -120,8 +344,7 @@ export function extractExports(contents: string): { exports: string, fnArgs: str
|
|
|
120
344
|
|
|
121
345
|
/**
|
|
122
346
|
* Convert a function to a string (results in loss of context!)
|
|
123
|
-
* Handles named, async, and arrow functions
|
|
124
347
|
*/
|
|
125
|
-
export function functionToString(fn:
|
|
126
|
-
return fn.toString().trim();
|
|
127
|
-
}
|
|
348
|
+
export function functionToString(fn: unknown) {
|
|
349
|
+
return (fn as (...args: unknown[]) => unknown).toString().trim();
|
|
350
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { Hyperspan as HS } from '../types';
|
|
2
|
+
|
|
3
|
+
export type AssetManifest = {
|
|
4
|
+
imports: Record<string, string>;
|
|
5
|
+
css: Record<string, string[]>;
|
|
6
|
+
clients: Record<string, string>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
let _manifest: AssetManifest = {
|
|
10
|
+
imports: {},
|
|
11
|
+
css: {},
|
|
12
|
+
clients: {},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Set the build-time asset manifest (called by Vite plugin or build step).
|
|
17
|
+
*/
|
|
18
|
+
export function setAssetManifest(manifest: AssetManifest): void {
|
|
19
|
+
_manifest = manifest;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get the current asset manifest.
|
|
24
|
+
*/
|
|
25
|
+
export function getAssetManifest(): AssetManifest {
|
|
26
|
+
return _manifest;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve a module import name to its public URL.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveImport(name: string): string | undefined {
|
|
33
|
+
return _manifest.imports[name];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get CSS files for a route path.
|
|
38
|
+
*/
|
|
39
|
+
export function getRouteCss(path: string): string[] {
|
|
40
|
+
return _manifest.css[path] ?? [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build import map object for layout script tags.
|
|
45
|
+
*/
|
|
46
|
+
export function getImportMap(): Record<string, string> {
|
|
47
|
+
return { ..._manifest.imports };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Register a client module URL at runtime (dev fallback).
|
|
52
|
+
*/
|
|
53
|
+
export function registerImport(name: string, publicPath: string): void {
|
|
54
|
+
_manifest.imports[name] = publicPath;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type ClientJSBuildResult = HS.ClientJSBuildResult;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Create a ClientJSBuildResult from manifest data (replaces runtime Bun.build).
|
|
61
|
+
*/
|
|
62
|
+
export function getClientJSFromManifest(
|
|
63
|
+
moduleId: string,
|
|
64
|
+
_exportNames = 'default'
|
|
65
|
+
): ClientJSBuildResult {
|
|
66
|
+
const publicPath = _manifest.clients[moduleId];
|
|
67
|
+
if (!publicPath) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`[Hyperspan] Client module "${moduleId}" not found in asset manifest. Run hyperspan build or start dev server.`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const esmName = publicPath.split('/').pop()?.replace('.js', '') ?? moduleId;
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
assetHash: moduleId,
|
|
77
|
+
esmName,
|
|
78
|
+
publicPath,
|
|
79
|
+
renderScriptTag: () => {
|
|
80
|
+
throw new Error('renderScriptTag requires html template - use layout helpers instead');
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { createRoute, createServer } from './server';
|
|
3
|
+
import { createFetchHandler, compileRoutePath } from './fetch-handler';
|
|
4
|
+
import type { Hyperspan as HS } from './types';
|
|
5
|
+
|
|
6
|
+
describe('compileRoutePath', () => {
|
|
7
|
+
test('matches static paths', () => {
|
|
8
|
+
const { pattern } = compileRoutePath('/about');
|
|
9
|
+
expect('/about'.match(pattern)).toBeTruthy();
|
|
10
|
+
expect('/about/'.match(pattern)).toBeTruthy();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('matches dynamic params', () => {
|
|
14
|
+
const { pattern, paramNames } = compileRoutePath('/users/:id');
|
|
15
|
+
expect(paramNames).toEqual(['id']);
|
|
16
|
+
const match = '/users/42'.match(pattern);
|
|
17
|
+
expect(match?.[1]).toBe('42');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('matches wildcard routes', () => {
|
|
21
|
+
const { pattern, paramNames } = compileRoutePath('/blog/*');
|
|
22
|
+
expect(paramNames).toEqual(['...slug']);
|
|
23
|
+
const match = '/blog/hello/world'.match(pattern);
|
|
24
|
+
expect(match?.[1]).toBe('hello/world');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('createFetchHandler', () => {
|
|
29
|
+
test('routes GET requests to matching handler', async () => {
|
|
30
|
+
const server = await createServer({
|
|
31
|
+
appDir: './app',
|
|
32
|
+
publicDir: './public',
|
|
33
|
+
plugins: [],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
server.get('/hello', (c: HS.Context) => c.res.html('<h1>Hello</h1>'));
|
|
37
|
+
|
|
38
|
+
const fetch = createFetchHandler(server);
|
|
39
|
+
const response = await fetch(new Request('http://localhost/hello'));
|
|
40
|
+
|
|
41
|
+
expect(response.status).toBe(200);
|
|
42
|
+
expect(await response.text()).toBe('<h1>Hello</h1>');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('returns 404 for unknown paths', async () => {
|
|
46
|
+
const server = await createServer({
|
|
47
|
+
appDir: './app',
|
|
48
|
+
publicDir: './public',
|
|
49
|
+
plugins: [],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const fetch = createFetchHandler(server);
|
|
53
|
+
const response = await fetch(new Request('http://localhost/missing'));
|
|
54
|
+
|
|
55
|
+
expect(response.status).toBe(404);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('passes route params to context', async () => {
|
|
59
|
+
const server = await createServer({
|
|
60
|
+
appDir: './app',
|
|
61
|
+
publicDir: './public',
|
|
62
|
+
plugins: [],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const route = createRoute({ path: '/items/:id' }).get((c: HS.Context) =>
|
|
66
|
+
c.res.text(c.route.params.id ?? '')
|
|
67
|
+
);
|
|
68
|
+
server._routes.push(route);
|
|
69
|
+
|
|
70
|
+
const fetch = createFetchHandler(server);
|
|
71
|
+
const response = await fetch(new Request('http://localhost/items/abc'));
|
|
72
|
+
|
|
73
|
+
expect(await response.text()).toBe('abc');
|
|
74
|
+
});
|
|
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
|
+
|
|
99
|
+
test('redirects trailing slashes', async () => {
|
|
100
|
+
const server = await createServer({
|
|
101
|
+
appDir: './app',
|
|
102
|
+
publicDir: './public',
|
|
103
|
+
plugins: [],
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
server.get('/page', (c: HS.Context) => c.res.text('ok'));
|
|
107
|
+
|
|
108
|
+
const fetch = createFetchHandler(server);
|
|
109
|
+
const response = await fetch(new Request('http://localhost/page/', { redirect: 'manual' }));
|
|
110
|
+
|
|
111
|
+
expect(response.status).toBe(308);
|
|
112
|
+
});
|
|
113
|
+
});
|