@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspan/framework",
3
- "version": "2.0.0-alpha.5",
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,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
- * 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.
65
104
  */
66
- export function resolveClientModulePath(modulePathResolved: string): string {
67
- if (modulePathResolved.startsWith('file://')) {
68
- 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);
69
117
  }
70
- return modulePathResolved;
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
- * Resolve a client module path for hashing and bundling.
75
- * Logical app-relative paths (e.g. app/client/foo.ts) hash consistently across
76
- * 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.
77
130
  */
78
- export function resolveClientModulePaths(modulePathResolved: string): {
79
- hashKey: string;
80
- absPath: string;
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
- return { hashKey: resolved, absPath: resolved };
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 registerClientJSEntry(entry: ClientJSEntry): void {
111
- getClientJSRegistry().set(entry.esmName, entry);
112
- }
113
-
114
- /**
115
- * Build (or look up) a client JS module and return a helper for rendering script tags.
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
- registerClientJSEntry({ absPath, assetHash: hash, esmName, publicPath, exports, fnArgs });
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.
@@ -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 as keyof AssetManifest['clients']];
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, getClientJSFromManifest } from './client/manifest';
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
- let streamingPath: string;
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
- try {
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
  }