@hyperspan/framework 2.0.0-alpha.5 → 2.0.0-alpha.7
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 +1 -1
- package/src/client/js.test.ts +58 -1
- package/src/client/js.ts +126 -38
- package/src/client/manifest.ts +2 -6
- package/src/index.ts +2 -1
- package/src/layout.test.ts +12 -0
- package/src/layout.ts +10 -18
package/package.json
CHANGED
package/src/client/js.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test, beforeEach } from 'vitest';
|
|
2
|
-
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { mkdtempSync, writeFileSync, existsSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { render } from '@hyperspan/html';
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
discoverClientExports,
|
|
9
9
|
extractExports,
|
|
10
10
|
getClientJSEntries,
|
|
11
|
+
registerPathAliases,
|
|
11
12
|
resetClientJSEntriesForTests,
|
|
12
13
|
} from './js';
|
|
13
14
|
|
|
@@ -63,6 +64,31 @@ describe('buildClientJS', () => {
|
|
|
63
64
|
expect(tag).toContain('({ mountPicker }) => mountPicker()');
|
|
64
65
|
});
|
|
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
|
+
|
|
66
92
|
test('logical app-relative paths hash consistently and resolve on disk when present', async () => {
|
|
67
93
|
const dir = mkdtempSync(join(tmpdir(), 'hs-client-logical-'));
|
|
68
94
|
const { mkdirSync } = await import('node:fs');
|
|
@@ -84,6 +110,37 @@ describe('buildClientJS', () => {
|
|
|
84
110
|
process.chdir(cwd);
|
|
85
111
|
}
|
|
86
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
|
+
});
|
|
87
144
|
});
|
|
88
145
|
|
|
89
146
|
describe('extractExports', () => {
|
package/src/client/js.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
-
import { isAbsolute, join } from 'node:path';
|
|
2
|
+
import { isAbsolute, join, relative } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { html } from '@hyperspan/html';
|
|
5
5
|
import { assetHash as assetHashFn } from '../utils';
|
|
@@ -11,16 +11,54 @@ export { registerImport, resolveImport, getImportMap } from './manifest';
|
|
|
11
11
|
export const JS_PUBLIC_PATH = '/_hs/js';
|
|
12
12
|
export const JS_ISLAND_PUBLIC_PATH = '/_hs/js/islands';
|
|
13
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
|
+
|
|
14
21
|
export type ClientJSEntry = {
|
|
22
|
+
/** Original path/specifier passed to `buildClientJS`. */
|
|
23
|
+
modulePath: string;
|
|
15
24
|
absPath: string;
|
|
16
25
|
assetHash: string;
|
|
17
26
|
esmName: string;
|
|
18
27
|
publicPath: string;
|
|
19
28
|
exports: string;
|
|
20
29
|
fnArgs: string;
|
|
30
|
+
type: ClientJSType;
|
|
21
31
|
};
|
|
22
32
|
|
|
23
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
|
+
}
|
|
24
62
|
|
|
25
63
|
function getClientJSRegistry(): Map<string, ClientJSEntry> {
|
|
26
64
|
const globalRegistry = globalThis as {
|
|
@@ -61,38 +99,56 @@ export const JS_IMPORT_MAP = {
|
|
|
61
99
|
};
|
|
62
100
|
|
|
63
101
|
/**
|
|
64
|
-
*
|
|
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.
|
|
65
104
|
*/
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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);
|
|
69
117
|
}
|
|
70
|
-
return
|
|
118
|
+
return normalized;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function toFilePath(modulePath: string): string {
|
|
122
|
+
return modulePath.startsWith('file://') ? fileURLToPath(modulePath) : modulePath;
|
|
71
123
|
}
|
|
72
124
|
|
|
73
125
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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.
|
|
77
130
|
*/
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const resolved = resolveClientModulePath(modulePathResolved).replace(/\\/g, '/');
|
|
83
|
-
|
|
84
|
-
if (!isAbsolute(resolved)) {
|
|
85
|
-
const hashKey = resolved;
|
|
86
|
-
if (typeof process !== 'undefined' && typeof process.cwd === 'function') {
|
|
87
|
-
const candidate = join(process.cwd(), hashKey);
|
|
88
|
-
if (existsSync(candidate)) {
|
|
89
|
-
return { hashKey, absPath: candidate };
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
return { hashKey, absPath: hashKey };
|
|
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 };
|
|
93
135
|
}
|
|
94
136
|
|
|
95
|
-
|
|
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) };
|
|
96
152
|
}
|
|
97
153
|
|
|
98
154
|
export function getClientJSEntries(): ClientJSEntry[] {
|
|
@@ -105,20 +161,18 @@ export function getClientJSEntryByEsmName(esmName: string): ClientJSEntry | unde
|
|
|
105
161
|
|
|
106
162
|
export function resetClientJSEntriesForTests(): void {
|
|
107
163
|
getClientJSRegistry().clear();
|
|
164
|
+
const aliases = getPathAliases();
|
|
165
|
+
for (const key of Object.keys(aliases)) {
|
|
166
|
+
delete aliases[key];
|
|
167
|
+
}
|
|
108
168
|
}
|
|
109
169
|
|
|
110
|
-
function
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
*
|
|
117
|
-
* Never evaluates the module on the server — only registers the path for Vite to bundle
|
|
118
|
-
* and returns URLs / script-tag helpers for the browser.
|
|
119
|
-
*/
|
|
120
|
-
export async function buildClientJS(modulePathResolved: string): Promise<HS.ClientJSBuildResult> {
|
|
121
|
-
const { hashKey, absPath } = resolveClientModulePaths(modulePathResolved);
|
|
170
|
+
function registerClientJS(
|
|
171
|
+
modulePathResolved: string,
|
|
172
|
+
options: BuildClientJSOptions = {}
|
|
173
|
+
): HS.ClientJSBuildResult {
|
|
174
|
+
const type = options.type ?? 'module';
|
|
175
|
+
const { hashKey, absPath } = resolveClientModule(modulePathResolved);
|
|
122
176
|
const hash = assetHashFn(hashKey);
|
|
123
177
|
const esmName = `client-${hash}`;
|
|
124
178
|
const publicPath = resolveImport(esmName) ?? `${JS_PUBLIC_PATH}/${esmName}.js`;
|
|
@@ -133,7 +187,16 @@ export async function buildClientJS(modulePathResolved: string): Promise<HS.Clie
|
|
|
133
187
|
fnArgs = discovered.fnArgs;
|
|
134
188
|
}
|
|
135
189
|
|
|
136
|
-
|
|
190
|
+
getClientJSRegistry().set(esmName, {
|
|
191
|
+
modulePath: modulePathResolved,
|
|
192
|
+
absPath,
|
|
193
|
+
assetHash: hash,
|
|
194
|
+
esmName,
|
|
195
|
+
publicPath,
|
|
196
|
+
exports,
|
|
197
|
+
fnArgs,
|
|
198
|
+
type,
|
|
199
|
+
});
|
|
137
200
|
registerImport(esmName, publicPath);
|
|
138
201
|
|
|
139
202
|
return {
|
|
@@ -141,6 +204,10 @@ export async function buildClientJS(modulePathResolved: string): Promise<HS.Clie
|
|
|
141
204
|
esmName,
|
|
142
205
|
publicPath,
|
|
143
206
|
renderScriptTag: (loadScript) => {
|
|
207
|
+
if (type === 'iife') {
|
|
208
|
+
return html`<script src="${publicPath}"></script>`;
|
|
209
|
+
}
|
|
210
|
+
|
|
144
211
|
const t = typeof loadScript;
|
|
145
212
|
|
|
146
213
|
if (t === 'string') {
|
|
@@ -169,6 +236,27 @@ export async function buildClientJS(modulePathResolved: string): Promise<HS.Clie
|
|
|
169
236
|
};
|
|
170
237
|
}
|
|
171
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
|
+
|
|
172
260
|
/**
|
|
173
261
|
* Discover export names from source or bundled client JS.
|
|
174
262
|
* Prefers bundled `export{...}` form, then common source export patterns.
|
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.`
|
package/src/index.ts
CHANGED
|
@@ -19,7 +19,8 @@ export {
|
|
|
19
19
|
getImportMap,
|
|
20
20
|
registerImport,
|
|
21
21
|
} from './client/manifest';
|
|
22
|
-
export { buildClientJS } from './client/js';
|
|
22
|
+
export { buildClientJS, registerPathAliases } from './client/js';
|
|
23
|
+
export type { BuildClientJSOptions, ClientJSType } from './client/js';
|
|
23
24
|
export { registerRouteModule, registerRouteModules } from './register-routes';
|
|
24
25
|
export type { RouteModuleEntry } from './register-routes';
|
|
25
26
|
export type { AssetManifest } from './client/manifest';
|
|
@@ -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
|
}
|