@hyperspan/framework 2.0.0-alpha.14 → 2.0.0-alpha.15

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.14",
3
+ "version": "2.0.0-alpha.15",
4
4
  "description": "Hyperspan Web Framework",
5
5
  "type": "module",
6
6
  "main": "src/server.ts",
@@ -104,7 +104,7 @@
104
104
  "vitest": "^4.1.10"
105
105
  },
106
106
  "dependencies": {
107
- "@hyperspan/html": "^2.0.0-alpha.14",
107
+ "@hyperspan/html": "^2.0.0-alpha.15",
108
108
  "debug": "^4.4.3",
109
109
  "isbot": "^5.1.32",
110
110
  "zod": "^4.4.3"
@@ -2,7 +2,9 @@ import { describe, expect, test, beforeEach } from 'vitest';
2
2
  import { mkdtempSync, writeFileSync, existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
+ import { pathToFileURL } from 'node:url';
5
6
  import { render } from '@hyperspan/html';
7
+ import { setAssetManifest } from './manifest';
6
8
  import {
7
9
  buildClientJS,
8
10
  discoverClientExports,
@@ -10,6 +12,8 @@ import {
10
12
  getClientJSEntries,
11
13
  registerPathAliases,
12
14
  resetClientJSEntriesForTests,
15
+ stableClientSourceKey,
16
+ streamingClient,
13
17
  } from './js';
14
18
 
15
19
  describe('buildClientJS', () => {
@@ -17,6 +21,7 @@ describe('buildClientJS', () => {
17
21
 
18
22
  beforeEach(() => {
19
23
  resetClientJSEntriesForTests();
24
+ setAssetManifest({ imports: {}, css: {}, clients: {}, clientSources: {} });
20
25
  const dir = mkdtempSync(join(tmpdir(), 'hs-client-'));
21
26
  clientFile = join(dir, 'picker.ts');
22
27
  writeFileSync(
@@ -28,7 +33,7 @@ describe('buildClientJS', () => {
28
33
  );
29
34
  });
30
35
 
31
- test('registers entry and returns stable esmName from path hash', async () => {
36
+ test('registers entry with a stable path identity', async () => {
32
37
  const result = await buildClientJS(clientFile);
33
38
 
34
39
  expect(result.esmName).toMatch(/^client-[0-9a-f]{16}$/);
@@ -37,11 +42,38 @@ describe('buildClientJS', () => {
37
42
  expect(getClientJSEntries()[0].absPath).toBe(clientFile);
38
43
  });
39
44
 
45
+ test('changing file contents does not change the import-map key', async () => {
46
+ const first = await buildClientJS(clientFile);
47
+ writeFileSync(clientFile, `export function mountPicker() { return 2; }\n`);
48
+ resetClientJSEntriesForTests();
49
+ const second = await buildClientJS(clientFile);
50
+ expect(second.esmName).toBe(first.esmName);
51
+ });
52
+
53
+ test('publicPath uses the Vite-hashed URL from the import map', async () => {
54
+ const result = await buildClientJS(clientFile);
55
+ const hashed = `/_hs/js/${result.esmName}-a1b2c3d4.js`;
56
+ setAssetManifest({
57
+ imports: { [result.esmName]: hashed },
58
+ css: {},
59
+ clients: {},
60
+ clientSources: {},
61
+ });
62
+ expect(result.publicPath).toBe(hashed);
63
+ });
64
+
65
+ test('absolute path and file URL of the same file share one hash', async () => {
66
+ const viaAbs = await buildClientJS(clientFile);
67
+ resetClientJSEntriesForTests();
68
+ const viaUrl = await buildClientJS(pathToFileURL(clientFile).href);
69
+ expect(viaUrl.esmName).toBe(viaAbs.esmName);
70
+ });
71
+
40
72
  test('renderScriptTag with no loader uses import map key', async () => {
41
73
  const result = await buildClientJS(clientFile);
42
74
  const tag = render(result.renderScriptTag());
43
75
 
44
- expect(tag).toContain(`import "${result.esmName}"`);
76
+ expect(tag).toContain(`import '${result.esmName}'`);
45
77
  expect(tag).toContain(`data-source-id="${result.assetHash}"`);
46
78
  });
47
79
 
@@ -53,7 +85,7 @@ describe('buildClientJS', () => {
53
85
  })
54
86
  );
55
87
 
56
- expect(tag).toContain(`import {mountPicker} from "${result.esmName}"`);
88
+ expect(tag).toContain(`import {mountPicker} from '${result.esmName}'`);
57
89
  expect(tag).toContain('mountPicker()');
58
90
  });
59
91
 
@@ -64,11 +96,22 @@ describe('buildClientJS', () => {
64
96
  expect(tag).toContain('({ mountPicker }) => mountPicker()');
65
97
  });
66
98
 
99
+ test('package specifiers and import.meta.resolve of the same file share one hash', async () => {
100
+ const spec = '@hyperspan/framework/client/_hs/hyperspan-streaming.client.ts';
101
+ const first = await buildClientJS(spec, { type: 'iife' });
102
+ resetClientJSEntriesForTests();
103
+ const second = await buildClientJS(spec, { type: 'iife' });
104
+ expect(first.esmName).toBe(second.esmName);
105
+ expect(first.esmName).toBe(streamingClient.esmName);
106
+ expect(existsSync(getClientJSEntries()[0].absPath)).toBe(true);
107
+ });
108
+
67
109
  test('import.meta.resolve at the call site registers a real file', async () => {
68
110
  const result = await buildClientJS(import.meta.resolve('./_hs/hyperspan-streaming.client.ts'), {
69
111
  type: 'iife',
70
112
  });
71
113
 
114
+ expect(result.esmName).toBe(streamingClient.esmName);
72
115
  expect(result.publicPath).toMatch(/^\/_hs\/js\/client-[0-9a-f]{16}\.js$/);
73
116
  expect(getClientJSEntries()[0].type).toBe('iife');
74
117
  expect(existsSync(getClientJSEntries()[0].absPath)).toBe(true);
@@ -132,14 +175,19 @@ describe('buildClientJS', () => {
132
175
  expect(second.esmName).toBe(result.esmName);
133
176
  });
134
177
 
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);
178
+ test('missing files use a build-time hash from clientSources', async () => {
179
+ const ghost = '/definitely-missing-hyperspan-root/app/client/gone.ts';
180
+ const builtName = 'client-from-build0123';
181
+ setAssetManifest({
182
+ imports: { [builtName]: `/_hs/js/${builtName}.js` },
183
+ css: {},
184
+ clients: {},
185
+ clientSources: { [stableClientSourceKey(ghost)]: builtName },
186
+ });
187
+
188
+ const result = await buildClientJS(ghost);
189
+ expect(result.esmName).toBe(builtName);
190
+ expect(result.publicPath).toBe(`/_hs/js/${builtName}.js`);
143
191
  });
144
192
  });
145
193
 
package/src/client/js.ts CHANGED
@@ -3,7 +3,7 @@ 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';
6
- import { getImportMap, registerImport, resolveImport } from './manifest';
6
+ import { getAssetManifest, getImportMap, registerImport, resolveImport } from './manifest';
7
7
  import type { Hyperspan as HS } from '../types';
8
8
 
9
9
  export { registerImport, resolveImport, getImportMap } from './manifest';
@@ -22,6 +22,9 @@ export type ClientJSEntry = {
22
22
  /** Original path/specifier passed to `buildClientJS`. */
23
23
  modulePath: string;
24
24
  absPath: string;
25
+ /** Identities used to look up a stable import-map key when the source file is gone (Workers). */
26
+ sourceKeys: string[];
27
+ /** Identity hash of the resolved path (not file contents). */
25
28
  assetHash: string;
26
29
  esmName: string;
27
30
  publicPath: string;
@@ -99,22 +102,23 @@ export const JS_IMPORT_MAP = {
99
102
  };
100
103
 
101
104
  /**
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.
105
+ * File URLs from `import.meta.resolve` must not use a cwd-relative pnpm path —
106
+ * that changes between install layouts and Workers. Prefer the path after the
107
+ * last `node_modules/` so published packages stay stable.
104
108
  */
105
- function stableHashKey(absPath: string): string {
109
+ export function stableClientSourceKey(absPath: string): string {
106
110
  const normalized = absPath.replace(/\\/g, '/');
111
+ const marker = '/node_modules/';
112
+ const idx = normalized.lastIndexOf(marker);
113
+ if (idx !== -1) {
114
+ return normalized.slice(idx + marker.length);
115
+ }
107
116
  if (typeof process !== 'undefined' && typeof process.cwd === 'function') {
108
117
  const rel = relative(process.cwd(), absPath).replace(/\\/g, '/');
109
118
  if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
110
119
  return rel;
111
120
  }
112
121
  }
113
- const marker = '/node_modules/';
114
- const idx = normalized.lastIndexOf(marker);
115
- if (idx !== -1) {
116
- return normalized.slice(idx + marker.length);
117
- }
118
122
  return normalized;
119
123
  }
120
124
 
@@ -122,16 +126,35 @@ function toFilePath(modulePath: string): string {
122
126
  return modulePath.startsWith('file://') ? fileURLToPath(modulePath) : modulePath;
123
127
  }
124
128
 
129
+ function tryResolveToFile(specifier: string): string | undefined {
130
+ try {
131
+ if (typeof import.meta.resolve !== 'function') {
132
+ return undefined;
133
+ }
134
+ const resolved = import.meta.resolve(specifier);
135
+ if (typeof resolved === 'string' && (resolved.startsWith('file://') || isAbsolute(resolved))) {
136
+ return toFilePath(resolved);
137
+ }
138
+ } catch {
139
+ // Workers and unresolved package specifiers — Vite already emitted the asset.
140
+ }
141
+ return undefined;
142
+ }
143
+
144
+ function clientPublicPath(esmName: string): string {
145
+ return resolveImport(esmName) ?? `${JS_PUBLIC_PATH}/${esmName}.js`;
146
+ }
147
+
125
148
  /**
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.
149
+ * Resolve to a real file path. The import-map key hashes `stableClientSourceKey(absPath)`
150
+ * so `import.meta.resolve()`, aliases, and package specifiers of the same file share
151
+ * one key. Vite/esbuild attach a content hash to the emitted filename; `publicPath`
152
+ * reads that from the asset manifest. Relative `./` / `../` paths error — resolve
153
+ * them at the call site.
130
154
  */
131
- function resolveClientModule(modulePath: string): { hashKey: string; absPath: string } {
155
+ function resolveClientAbsPath(modulePath: string): string {
132
156
  if (modulePath.startsWith('file://') || isAbsolute(modulePath)) {
133
- const absPath = toFilePath(modulePath);
134
- return { hashKey: stableHashKey(absPath), absPath };
157
+ return toFilePath(modulePath);
135
158
  }
136
159
 
137
160
  const specifier = modulePath.replace(/\\/g, '/');
@@ -145,10 +168,41 @@ function resolveClientModule(modulePath: string): { hashKey: string; absPath: st
145
168
 
146
169
  const aliased = resolveWithAliases(specifier);
147
170
  if (aliased) {
148
- return { hashKey: specifier, absPath: aliased };
171
+ return aliased;
149
172
  }
150
173
 
151
- return { hashKey: specifier, absPath: join(process.cwd(), specifier) };
174
+ return tryResolveToFile(specifier) ?? join(process.cwd(), specifier);
175
+ }
176
+
177
+ function readClientSource(absPath: string): string | undefined {
178
+ try {
179
+ if (existsSync(absPath)) {
180
+ return readFileSync(absPath, 'utf-8');
181
+ }
182
+ } catch {
183
+ // Workers have no filesystem; Vite already bundled from this entry.
184
+ }
185
+ return undefined;
186
+ }
187
+
188
+ function clientSourceKeys(modulePath: string, absPath: string): string[] {
189
+ const keys = new Set<string>([stableClientSourceKey(absPath), modulePath.replace(/\\/g, '/')]);
190
+ if (modulePath.startsWith('file://')) {
191
+ keys.add(toFilePath(modulePath));
192
+ }
193
+ return [...keys];
194
+ }
195
+
196
+ function resolveEsmName(sourceKeys: string[], fallbackHash: string): string {
197
+ const sources = getAssetManifest().clientSources;
198
+ if (sources) {
199
+ for (const key of sourceKeys) {
200
+ if (sources[key]) {
201
+ return sources[key];
202
+ }
203
+ }
204
+ }
205
+ return `client-${fallbackHash}`;
152
206
  }
153
207
 
154
208
  export function getClientJSEntries(): ClientJSEntry[] {
@@ -156,7 +210,23 @@ export function getClientJSEntries(): ClientJSEntry[] {
156
210
  }
157
211
 
158
212
  export function getClientJSEntryByEsmName(esmName: string): ClientJSEntry | undefined {
159
- return getClientJSRegistry().get(esmName);
213
+ for (const entry of getClientJSRegistry().values()) {
214
+ if (currentEsmName(entry) === esmName) {
215
+ return entry;
216
+ }
217
+ }
218
+ return undefined;
219
+ }
220
+
221
+ export function getClientJSSourceMap(): Record<string, string> {
222
+ const map: Record<string, string> = {};
223
+ for (const entry of getClientJSEntries()) {
224
+ const esmName = currentEsmName(entry);
225
+ for (const key of entry.sourceKeys) {
226
+ map[key] = esmName;
227
+ }
228
+ }
229
+ return map;
160
230
  }
161
231
 
162
232
  export function resetClientJSEntriesForTests(): void {
@@ -167,52 +237,69 @@ export function resetClientJSEntriesForTests(): void {
167
237
  }
168
238
  }
169
239
 
240
+ function currentEsmName(entry: ClientJSEntry): string {
241
+ return resolveEsmName(entry.sourceKeys, entry.assetHash);
242
+ }
243
+
170
244
  function registerClientJS(
171
245
  modulePathResolved: string,
172
246
  options: BuildClientJSOptions = {}
173
247
  ): HS.ClientJSBuildResult {
174
248
  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`;
249
+ const absPath = resolveClientAbsPath(modulePathResolved);
250
+ const sourceKeys = clientSourceKeys(modulePathResolved, absPath);
251
+ const identityKey = stableClientSourceKey(absPath);
252
+ const hash = assetHashFn(identityKey);
253
+ const source = readClientSource(absPath);
179
254
 
180
255
  let exports = '* as _module';
181
256
  let fnArgs = '_module';
182
- const sourcePath = existsSync(absPath) ? absPath : null;
183
- if (sourcePath) {
184
- const source = readFileSync(sourcePath, 'utf-8');
257
+ if (source) {
185
258
  const discovered = discoverClientExports(source);
186
259
  exports = discovered.exports;
187
260
  fnArgs = discovered.fnArgs;
188
261
  }
189
262
 
190
- getClientJSRegistry().set(esmName, {
263
+ const entry: ClientJSEntry = {
191
264
  modulePath: modulePathResolved,
192
265
  absPath,
266
+ sourceKeys,
193
267
  assetHash: hash,
194
- esmName,
195
- publicPath,
268
+ get esmName() {
269
+ return currentEsmName(entry);
270
+ },
271
+ get publicPath() {
272
+ return clientPublicPath(currentEsmName(entry));
273
+ },
196
274
  exports,
197
275
  fnArgs,
198
276
  type,
199
- });
200
- registerImport(esmName, publicPath);
277
+ };
278
+ getClientJSRegistry().set(identityKey, entry);
279
+ registerImport(entry.esmName, entry.publicPath);
201
280
 
202
281
  return {
203
- assetHash: hash,
204
- esmName,
205
- publicPath,
282
+ get assetHash() {
283
+ return currentEsmName(entry).replace(/^client-/, '');
284
+ },
285
+ get esmName() {
286
+ return currentEsmName(entry);
287
+ },
288
+ get publicPath() {
289
+ return clientPublicPath(currentEsmName(entry));
290
+ },
206
291
  renderScriptTag: (loadScript) => {
292
+ const esmName = currentEsmName(entry);
293
+ const tagHash = esmName.replace(/^client-/, '');
207
294
  if (type === 'iife') {
208
- return html`<script src="${publicPath}"></script>`;
295
+ return html`<script src="${clientPublicPath(esmName)}"></script>`;
209
296
  }
210
297
 
211
298
  const t = typeof loadScript;
212
299
 
213
300
  if (t === 'string') {
214
301
  return html`
215
- <script type="module" data-source-id="${hash}">
302
+ <script type="module" data-source-id="${tagHash}">
216
303
  import ${exports} from '${esmName}';
217
304
  (${html.raw(loadScript as string)})(${fnArgs});
218
305
  </script>
@@ -220,7 +307,7 @@ function registerClientJS(
220
307
  }
221
308
  if (t === 'function') {
222
309
  return html`
223
- <script type="module" data-source-id="${hash}">
310
+ <script type="module" data-source-id="${tagHash}">
224
311
  import ${exports} from '${esmName}';
225
312
  (${html.raw(functionToString(loadScript))})(${fnArgs});
226
313
  </script>
@@ -228,7 +315,7 @@ function registerClientJS(
228
315
  }
229
316
 
230
317
  return html`
231
- <script type="module" data-source-id="${hash}">
318
+ <script type="module" data-source-id="${tagHash}">
232
319
  import '${esmName}';
233
320
  </script>
234
321
  `;
@@ -238,9 +325,9 @@ function registerClientJS(
238
325
 
239
326
  /**
240
327
  * 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')`.
328
+ * Prefer `import.meta.resolve('./file.ts')`, a tsconfig alias, or a project-root path.
329
+ * The import-map key is a stable identity of the resolved file. Vite/esbuild content-hash
330
+ * the emitted filename; `publicPath` uses that URL from the asset manifest when present.
244
331
  */
245
332
  export async function buildClientJS(
246
333
  modulePathResolved: string,
@@ -4,19 +4,27 @@ export type AssetManifest = {
4
4
  imports: Record<string, string>;
5
5
  css: Record<string, string[]>;
6
6
  clients: Record<string, string>;
7
+ /** Resolved path / original specifier → import-map key (`client-{id}`). */
8
+ clientSources?: Record<string, string>;
7
9
  };
8
10
 
9
11
  let _manifest: AssetManifest = {
10
12
  imports: {},
11
13
  css: {},
12
14
  clients: {},
15
+ clientSources: {},
13
16
  };
14
17
 
15
18
  /**
16
19
  * Set the build-time asset manifest (called by Vite plugin or build step).
17
20
  */
18
21
  export function setAssetManifest(manifest: AssetManifest): void {
19
- _manifest = manifest;
22
+ _manifest = {
23
+ imports: manifest.imports ?? {},
24
+ css: manifest.css ?? {},
25
+ clients: manifest.clients ?? {},
26
+ clientSources: { ...(manifest.clientSources ?? {}) },
27
+ };
20
28
  }
21
29
 
22
30
  /**
package/src/types.ts CHANGED
@@ -430,7 +430,7 @@ export namespace Hyperspan {
430
430
  * Client JS Module = ESM Module + Public Path + Render Script Tag
431
431
  */
432
432
  export type ClientJSBuildResult = {
433
- assetHash: string; // Asset hash of the module path
433
+ assetHash: string; // Identity of the resolved path; production file hash comes from Vite
434
434
  esmName: string; // Filename of the built JavaScript file without the extension
435
435
  publicPath: string; // Full public path of the built JavaScript file
436
436
  /**