@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspan/framework",
3
- "version": "2.0.0-alpha.6",
3
+ "version": "2.0.0-alpha.7",
4
4
  "description": "Hyperspan Web Framework",
5
5
  "type": "module",
6
6
  "main": "src/server.ts",
@@ -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('package export specifiers resolve on disk like app logical paths', async () => {
67
- const result = await buildClientJS(
68
- '@hyperspan/framework/client/_hs/hyperspan-streaming.client.ts',
69
- { type: 'iife' }
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
- * Resolve import.meta.resolve() / file URL / absolute path to a filesystem path.
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
- export function resolveClientModulePath(modulePathResolved: string): string {
75
- if (modulePathResolved.startsWith('file://')) {
76
- return fileURLToPath(modulePathResolved);
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 modulePathResolved;
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
- * Resolve a client module path for hashing and bundling.
83
- * Logical app-relative paths (e.g. app/client/foo.ts) hash consistently across
84
- * Node and edge runtimes where absolute paths differ.
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
- export function resolveClientModulePaths(modulePathResolved: string): {
87
- hashKey: string;
88
- absPath: string;
89
- } {
90
- const resolved = resolveClientModulePath(modulePathResolved).replace(/\\/g, '/');
91
-
92
- if (!isAbsolute(resolved)) {
93
- const hashKey = resolved;
94
- if (typeof process !== 'undefined' && typeof process.cwd === 'function') {
95
- const candidate = join(process.cwd(), hashKey);
96
- if (existsSync(candidate)) {
97
- return { hashKey, absPath: candidate };
98
- }
99
- }
100
- try {
101
- const url = import.meta.resolve(resolved);
102
- if (typeof url === 'string' && url.startsWith('file://')) {
103
- return { hashKey, absPath: fileURLToPath(url) };
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: resolved, absPath: resolved };
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
- function registerClientJSEntry(entry: ClientJSEntry): void {
127
- getClientJSRegistry().set(entry.esmName, entry);
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 } = resolveClientModulePaths(modulePathResolved);
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
- registerClientJSEntry({ absPath, assetHash: hash, esmName, publicPath, exports, fnArgs, type });
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
- * Build (or look up) a client JS module and return a helper for rendering script tags.
192
- *
193
- * Never evaluates the module on the server — only registers the path for Vite to bundle
194
- * and returns URLs / script-tag helpers for the browser.
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
- '@hyperspan/framework/client/_hs/hyperspan-streaming.client.ts',
253
+ import.meta.resolve('./_hs/hyperspan-streaming.client.ts'),
206
254
  { type: 'iife' }
207
255
  );
208
256
  export const actionsClient = registerClientJS(
209
- '@hyperspan/framework/client/_hs/hyperspan-actions.client.ts'
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';