@hyperspan/framework 2.0.0-alpha.6 → 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 +43 -6
- package/src/client/js.ts +94 -46
- package/src/index.ts +1 -1
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,17 +64,22 @@ describe('buildClientJS', () => {
|
|
|
63
64
|
expect(tag).toContain('({ mountPicker }) => mountPicker()');
|
|
64
65
|
});
|
|
65
66
|
|
|
66
|
-
test('
|
|
67
|
-
const result = await buildClientJS(
|
|
68
|
-
'
|
|
69
|
-
|
|
70
|
-
);
|
|
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
71
|
|
|
72
72
|
expect(result.publicPath).toMatch(/^\/_hs\/js\/client-[0-9a-f]{16}\.js$/);
|
|
73
73
|
expect(getClientJSEntries()[0].type).toBe('iife');
|
|
74
|
+
expect(existsSync(getClientJSEntries()[0].absPath)).toBe(true);
|
|
74
75
|
expect(getClientJSEntries()[0].absPath).toMatch(/hyperspan-streaming\.client\.ts$/);
|
|
75
76
|
});
|
|
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
|
+
|
|
77
83
|
test('type iife renders a classic script tag', async () => {
|
|
78
84
|
const result = await buildClientJS(clientFile, { type: 'iife' });
|
|
79
85
|
const tag = render(result.renderScriptTag());
|
|
@@ -104,6 +110,37 @@ describe('buildClientJS', () => {
|
|
|
104
110
|
process.chdir(cwd);
|
|
105
111
|
}
|
|
106
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
|
+
});
|
|
107
144
|
});
|
|
108
145
|
|
|
109
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';
|
|
@@ -19,6 +19,8 @@ export type BuildClientJSOptions = {
|
|
|
19
19
|
};
|
|
20
20
|
|
|
21
21
|
export type ClientJSEntry = {
|
|
22
|
+
/** Original path/specifier passed to `buildClientJS`. */
|
|
23
|
+
modulePath: string;
|
|
22
24
|
absPath: string;
|
|
23
25
|
assetHash: string;
|
|
24
26
|
esmName: string;
|
|
@@ -29,6 +31,34 @@ export type ClientJSEntry = {
|
|
|
29
31
|
};
|
|
30
32
|
|
|
31
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
|
+
}
|
|
32
62
|
|
|
33
63
|
function getClientJSRegistry(): Map<string, ClientJSEntry> {
|
|
34
64
|
const globalRegistry = globalThis as {
|
|
@@ -69,46 +99,56 @@ export const JS_IMPORT_MAP = {
|
|
|
69
99
|
};
|
|
70
100
|
|
|
71
101
|
/**
|
|
72
|
-
*
|
|
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.
|
|
73
104
|
*/
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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);
|
|
77
117
|
}
|
|
78
|
-
return
|
|
118
|
+
return normalized;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function toFilePath(modulePath: string): string {
|
|
122
|
+
return modulePath.startsWith('file://') ? fileURLToPath(modulePath) : modulePath;
|
|
79
123
|
}
|
|
80
124
|
|
|
81
125
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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.
|
|
85
130
|
*/
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
} catch {
|
|
106
|
-
// Bare specifiers that aren't installed yet still hash stably.
|
|
107
|
-
}
|
|
108
|
-
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 };
|
|
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 };
|
|
109
149
|
}
|
|
110
150
|
|
|
111
|
-
return { hashKey:
|
|
151
|
+
return { hashKey: specifier, absPath: join(process.cwd(), specifier) };
|
|
112
152
|
}
|
|
113
153
|
|
|
114
154
|
export function getClientJSEntries(): ClientJSEntry[] {
|
|
@@ -121,10 +161,10 @@ export function getClientJSEntryByEsmName(esmName: string): ClientJSEntry | unde
|
|
|
121
161
|
|
|
122
162
|
export function resetClientJSEntriesForTests(): void {
|
|
123
163
|
getClientJSRegistry().clear();
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
164
|
+
const aliases = getPathAliases();
|
|
165
|
+
for (const key of Object.keys(aliases)) {
|
|
166
|
+
delete aliases[key];
|
|
167
|
+
}
|
|
128
168
|
}
|
|
129
169
|
|
|
130
170
|
function registerClientJS(
|
|
@@ -132,7 +172,7 @@ function registerClientJS(
|
|
|
132
172
|
options: BuildClientJSOptions = {}
|
|
133
173
|
): HS.ClientJSBuildResult {
|
|
134
174
|
const type = options.type ?? 'module';
|
|
135
|
-
const { hashKey, absPath } =
|
|
175
|
+
const { hashKey, absPath } = resolveClientModule(modulePathResolved);
|
|
136
176
|
const hash = assetHashFn(hashKey);
|
|
137
177
|
const esmName = `client-${hash}`;
|
|
138
178
|
const publicPath = resolveImport(esmName) ?? `${JS_PUBLIC_PATH}/${esmName}.js`;
|
|
@@ -147,7 +187,16 @@ function registerClientJS(
|
|
|
147
187
|
fnArgs = discovered.fnArgs;
|
|
148
188
|
}
|
|
149
189
|
|
|
150
|
-
|
|
190
|
+
getClientJSRegistry().set(esmName, {
|
|
191
|
+
modulePath: modulePathResolved,
|
|
192
|
+
absPath,
|
|
193
|
+
assetHash: hash,
|
|
194
|
+
esmName,
|
|
195
|
+
publicPath,
|
|
196
|
+
exports,
|
|
197
|
+
fnArgs,
|
|
198
|
+
type,
|
|
199
|
+
});
|
|
151
200
|
registerImport(esmName, publicPath);
|
|
152
201
|
|
|
153
202
|
return {
|
|
@@ -188,10 +237,10 @@ function registerClientJS(
|
|
|
188
237
|
}
|
|
189
238
|
|
|
190
239
|
/**
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
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')`.
|
|
195
244
|
*/
|
|
196
245
|
export async function buildClientJS(
|
|
197
246
|
modulePathResolved: string,
|
|
@@ -200,13 +249,12 @@ export async function buildClientJS(
|
|
|
200
249
|
return registerClientJS(modulePathResolved, options);
|
|
201
250
|
}
|
|
202
251
|
|
|
203
|
-
/** Same `buildClientJS` path an app would use for a package export. */
|
|
204
252
|
export const streamingClient = registerClientJS(
|
|
205
|
-
'
|
|
253
|
+
import.meta.resolve('./_hs/hyperspan-streaming.client.ts'),
|
|
206
254
|
{ type: 'iife' }
|
|
207
255
|
);
|
|
208
256
|
export const actionsClient = registerClientJS(
|
|
209
|
-
'
|
|
257
|
+
import.meta.resolve('./_hs/hyperspan-actions.client.ts')
|
|
210
258
|
);
|
|
211
259
|
|
|
212
260
|
/**
|
package/src/index.ts
CHANGED
|
@@ -19,7 +19,7 @@ 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
23
|
export type { BuildClientJSOptions, ClientJSType } from './client/js';
|
|
24
24
|
export { registerRouteModule, registerRouteModules } from './register-routes';
|
|
25
25
|
export type { RouteModuleEntry } from './register-routes';
|