@hyperspan/framework 2.0.0-alpha.7 → 2.0.1

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.
@@ -1,8 +1,11 @@
1
1
  import { describe, expect, test, beforeEach } from 'vitest';
2
- import { mkdtempSync, writeFileSync, existsSync } from 'node:fs';
2
+ import { mkdtempSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
- import { render } from '@hyperspan/html';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { render } from '../html';
7
+ import { assetHash } from '../utils';
8
+ import { setAssetManifest } from './manifest';
6
9
  import {
7
10
  buildClientJS,
8
11
  discoverClientExports,
@@ -10,6 +13,7 @@ import {
10
13
  getClientJSEntries,
11
14
  registerPathAliases,
12
15
  resetClientJSEntriesForTests,
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: {} });
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,37 @@ 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
+ });
61
+ expect(result.publicPath).toBe(hashed);
62
+ });
63
+
64
+ test('absolute path and file URL of the same file share one hash', async () => {
65
+ const viaAbs = await buildClientJS(clientFile);
66
+ resetClientJSEntriesForTests();
67
+ const viaUrl = await buildClientJS(pathToFileURL(clientFile).href);
68
+ expect(viaUrl.esmName).toBe(viaAbs.esmName);
69
+ });
70
+
40
71
  test('renderScriptTag with no loader uses import map key', async () => {
41
72
  const result = await buildClientJS(clientFile);
42
73
  const tag = render(result.renderScriptTag());
43
74
 
44
- expect(tag).toContain(`import "${result.esmName}"`);
75
+ expect(tag).toContain(`import '${result.esmName}'`);
45
76
  expect(tag).toContain(`data-source-id="${result.assetHash}"`);
46
77
  });
47
78
 
@@ -53,7 +84,7 @@ describe('buildClientJS', () => {
53
84
  })
54
85
  );
55
86
 
56
- expect(tag).toContain(`import {mountPicker} from "${result.esmName}"`);
87
+ expect(tag).toContain(`import {mountPicker} from '${result.esmName}'`);
57
88
  expect(tag).toContain('mountPicker()');
58
89
  });
59
90
 
@@ -64,7 +95,33 @@ describe('buildClientJS', () => {
64
95
  expect(tag).toContain('({ mountPicker }) => mountPicker()');
65
96
  });
66
97
 
67
- test('import.meta.resolve at the call site registers a real file', async () => {
98
+ test('package specifiers hash the same wherever the package is installed', async () => {
99
+ const spec = '@hyperspan/framework/client/_hs/hyperspan-streaming.client.ts';
100
+ const first = await buildClientJS(spec, { type: 'iife' });
101
+ resetClientJSEntriesForTests();
102
+ const second = await buildClientJS(spec, { type: 'iife' });
103
+ expect(first.esmName).toBe(second.esmName);
104
+ expect(first.esmName).toBe(streamingClient.esmName);
105
+ expect(existsSync(getClientJSEntries()[0].absPath)).toBe(true);
106
+ });
107
+
108
+ // workerd rejects `import.meta.resolve` after bundling, which crashed Worker startup.
109
+ test('builtins register without resolving a path at module load', () => {
110
+ const src = readFileSync(new URL('./js.ts', import.meta.url), 'utf8');
111
+ const registrations = src.slice(src.indexOf('export const streamingClient'));
112
+
113
+ expect(registrations).toContain(
114
+ "'@hyperspan/framework/client/_hs/hyperspan-streaming.client.ts'"
115
+ );
116
+ expect(registrations).toContain(
117
+ "'@hyperspan/framework/client/_hs/hyperspan-actions.client.ts'"
118
+ );
119
+ expect(registrations).not.toContain('import.meta.resolve');
120
+ });
121
+
122
+ // Valid on Node/Bun, which can resolve paths at runtime. Flagged as a path identity
123
+ // so a Worker build can reject it instead of serving a 404 for a script in dist/.
124
+ test('import.meta.resolve at the call site registers a real file, flagged as a path identity', async () => {
68
125
  const result = await buildClientJS(import.meta.resolve('./_hs/hyperspan-streaming.client.ts'), {
69
126
  type: 'iife',
70
127
  });
@@ -73,11 +130,17 @@ describe('buildClientJS', () => {
73
130
  expect(getClientJSEntries()[0].type).toBe('iife');
74
131
  expect(existsSync(getClientJSEntries()[0].absPath)).toBe(true);
75
132
  expect(getClientJSEntries()[0].absPath).toMatch(/hyperspan-streaming\.client\.ts$/);
133
+ expect(getClientJSEntries()[0].identityFromPath).toBe(true);
134
+ });
135
+
136
+ test('specifier identities are not flagged as path identities', async () => {
137
+ await buildClientJS('@hyperspan/framework/client/_hs/hyperspan-actions.client.ts');
138
+ expect(getClientJSEntries()[0].identityFromPath).toBe(false);
76
139
  });
77
140
 
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/);
141
+ test('relative paths are rejected in favor of an alias', async () => {
142
+ await expect(buildClientJS('../client/picker.ts')).rejects.toThrow(/tsconfig alias/);
143
+ await expect(buildClientJS('./picker.ts')).rejects.toThrow(/tsconfig alias/);
81
144
  });
82
145
 
83
146
  test('type iife renders a classic script tag', async () => {
@@ -132,14 +195,16 @@ describe('buildClientJS', () => {
132
195
  expect(second.esmName).toBe(result.esmName);
133
196
  });
134
197
 
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);
198
+ // A Worker has no filesystem: registration must not read the source to get a URL.
199
+ test('missing source files still resolve to the built URL', async () => {
200
+ const spec = 'app/client/gone.ts';
201
+ const esmName = `client-${assetHash(spec)}`;
202
+ const hashed = `/_hs/js/${esmName}-a1b2c3d4.js`;
203
+ setAssetManifest({ imports: { [esmName]: hashed }, css: {}, clients: {} });
204
+
205
+ const result = await buildClientJS(spec);
206
+ expect(result.esmName).toBe(esmName);
207
+ expect(result.publicPath).toBe(hashed);
143
208
  });
144
209
  });
145
210
 
package/src/client/js.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFileSync, existsSync } from 'node:fs';
2
2
  import { isAbsolute, join, relative } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { html } from '@hyperspan/html';
4
+ import { html } from '../html';
5
5
  import { assetHash as assetHashFn } from '../utils';
6
6
  import { getImportMap, registerImport, resolveImport } from './manifest';
7
7
  import type { Hyperspan as HS } from '../types';
@@ -21,7 +21,17 @@ export type BuildClientJSOptions = {
21
21
  export type ClientJSEntry = {
22
22
  /** Original path/specifier passed to `buildClientJS`. */
23
23
  modulePath: string;
24
+ /** Resolved file path. Lazy — only a build/dev tool with a filesystem reads this. */
24
25
  absPath: string;
26
+ /** Runtime-stable identity of the module. Same value on Vite, Node, and Workers. */
27
+ identityKey: string;
28
+ /**
29
+ * Identity came from a filesystem path rather than a logical specifier. Such an
30
+ * identity only matches at runtime on a runtime that can resolve paths, so it is
31
+ * not portable to a Worker.
32
+ */
33
+ identityFromPath: boolean;
34
+ /** Identity hash of `identityKey` (not of file contents). */
25
35
  assetHash: string;
26
36
  esmName: string;
27
37
  publicPath: string;
@@ -30,7 +40,13 @@ export type ClientJSEntry = {
30
40
  type: ClientJSType;
31
41
  };
32
42
 
43
+ export type ClientJSPathIdentity = {
44
+ modulePath: string;
45
+ identityKey: string;
46
+ };
47
+
33
48
  const CLIENT_JS_REGISTRY = Symbol.for('@hyperspan/client-js-entries');
49
+ const CLIENT_JS_PATH_IDENTITIES = Symbol.for('@hyperspan/client-js-path-identities');
34
50
  const PATH_ALIASES = Symbol.for('@hyperspan/path-aliases');
35
51
 
36
52
  function getPathAliases(): Record<string, string> {
@@ -60,6 +76,23 @@ function resolveWithAliases(specifier: string): string | undefined {
60
76
  return undefined;
61
77
  }
62
78
 
79
+ /**
80
+ * Client scripts registered from a path rather than a logical specifier, kept outside
81
+ * the registry. A path identity can coincide with the equivalent project-relative
82
+ * specifier (`file:///proj/app/client/x.ts` and `app/client/x.ts` share one identity),
83
+ * so the registry entry may be replaced by whichever form registered last. Deploy
84
+ * checks need the record regardless of that order.
85
+ */
86
+ export function getPathIdentityClientJS(): ClientJSPathIdentity[] {
87
+ const globalPathIdentities = globalThis as {
88
+ [CLIENT_JS_PATH_IDENTITIES]?: ClientJSPathIdentity[];
89
+ };
90
+ if (!globalPathIdentities[CLIENT_JS_PATH_IDENTITIES]) {
91
+ globalPathIdentities[CLIENT_JS_PATH_IDENTITIES] = [];
92
+ }
93
+ return globalPathIdentities[CLIENT_JS_PATH_IDENTITIES];
94
+ }
95
+
63
96
  function getClientJSRegistry(): Map<string, ClientJSEntry> {
64
97
  const globalRegistry = globalThis as {
65
98
  [CLIENT_JS_REGISTRY]?: Map<string, ClientJSEntry>;
@@ -99,22 +132,23 @@ export const JS_IMPORT_MAP = {
99
132
  };
100
133
 
101
134
  /**
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.
135
+ * File paths must not become a cwd-relative pnpm path — that changes between
136
+ * install layouts and Workers. Prefer the path after the last `node_modules/`
137
+ * so published packages stay stable.
104
138
  */
105
- function stableHashKey(absPath: string): string {
139
+ export function stableClientSourceKey(absPath: string): string {
106
140
  const normalized = absPath.replace(/\\/g, '/');
141
+ const marker = '/node_modules/';
142
+ const idx = normalized.lastIndexOf(marker);
143
+ if (idx !== -1) {
144
+ return normalized.slice(idx + marker.length);
145
+ }
107
146
  if (typeof process !== 'undefined' && typeof process.cwd === 'function') {
108
147
  const rel = relative(process.cwd(), absPath).replace(/\\/g, '/');
109
148
  if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
110
149
  return rel;
111
150
  }
112
151
  }
113
- const marker = '/node_modules/';
114
- const idx = normalized.lastIndexOf(marker);
115
- if (idx !== -1) {
116
- return normalized.slice(idx + marker.length);
117
- }
118
152
  return normalized;
119
153
  }
120
154
 
@@ -122,33 +156,68 @@ function toFilePath(modulePath: string): string {
122
156
  return modulePath.startsWith('file://') ? fileURLToPath(modulePath) : modulePath;
123
157
  }
124
158
 
159
+ function tryResolveToFile(specifier: string): string | undefined {
160
+ try {
161
+ if (typeof import.meta.resolve !== 'function') {
162
+ return undefined;
163
+ }
164
+ const resolved = import.meta.resolve(specifier);
165
+ if (typeof resolved === 'string' && (resolved.startsWith('file://') || isAbsolute(resolved))) {
166
+ return toFilePath(resolved);
167
+ }
168
+ } catch {
169
+ // Workers and unresolved package specifiers — Vite already emitted the asset.
170
+ }
171
+ return undefined;
172
+ }
173
+
174
+ function clientPublicPath(esmName: string): string {
175
+ return resolveImport(esmName) ?? `${JS_PUBLIC_PATH}/${esmName}.js`;
176
+ }
177
+
125
178
  /**
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.
179
+ * Identity of a client module, computed with string math only — no filesystem and
180
+ * no `import.meta.resolve`. Workers can therefore build the same identity (and so
181
+ * the same public URL) that Vite used at build time.
182
+ *
183
+ * A logical specifier (package export, tsconfig alias, project-root path) is its
184
+ * own identity. File URLs and absolute paths fall back to a path identity.
130
185
  */
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 };
186
+ function clientIdentityKey(specifier: string): string {
187
+ if (specifier.startsWith('file://') || isAbsolute(specifier)) {
188
+ return stableClientSourceKey(toFilePath(specifier));
135
189
  }
190
+ return specifier;
191
+ }
136
192
 
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
- );
193
+ /**
194
+ * Resolve an identity to a real file path. Only build/dev tooling calls this, so
195
+ * `import.meta.resolve` never runs during module load on a Worker.
196
+ */
197
+ function resolveClientAbsPath(specifier: string): string {
198
+ if (specifier.startsWith('file://') || isAbsolute(specifier)) {
199
+ return toFilePath(specifier);
144
200
  }
145
201
 
146
202
  const aliased = resolveWithAliases(specifier);
147
203
  if (aliased) {
148
- return { hashKey: specifier, absPath: aliased };
204
+ return aliased;
149
205
  }
150
206
 
151
- return { hashKey: specifier, absPath: join(process.cwd(), specifier) };
207
+ const cwd =
208
+ typeof process !== 'undefined' && typeof process.cwd === 'function' ? process.cwd() : '';
209
+ return tryResolveToFile(specifier) ?? (cwd ? join(cwd, specifier) : specifier);
210
+ }
211
+
212
+ function readClientSource(absPath: string): string | undefined {
213
+ try {
214
+ if (existsSync(absPath)) {
215
+ return readFileSync(absPath, 'utf-8');
216
+ }
217
+ } catch {
218
+ // Workers have no filesystem; Vite already bundled from this entry.
219
+ }
220
+ return undefined;
152
221
  }
153
222
 
154
223
  export function getClientJSEntries(): ClientJSEntry[] {
@@ -161,6 +230,7 @@ export function getClientJSEntryByEsmName(esmName: string): ClientJSEntry | unde
161
230
 
162
231
  export function resetClientJSEntriesForTests(): void {
163
232
  getClientJSRegistry().clear();
233
+ getPathIdentityClientJS().length = 0;
164
234
  const aliases = getPathAliases();
165
235
  for (const key of Object.keys(aliases)) {
166
236
  delete aliases[key];
@@ -172,43 +242,79 @@ function registerClientJS(
172
242
  options: BuildClientJSOptions = {}
173
243
  ): HS.ClientJSBuildResult {
174
244
  const type = options.type ?? 'module';
175
- const { hashKey, absPath } = resolveClientModule(modulePathResolved);
176
- const hash = assetHashFn(hashKey);
245
+ const specifier = modulePathResolved.replace(/\\/g, '/');
246
+ if (specifier.startsWith('.')) {
247
+ throw new Error(
248
+ `[Hyperspan] buildClientJS(${JSON.stringify(modulePathResolved)}) got a relative path, ` +
249
+ `which has no stable identity across dev, build, and deploy. Use a tsconfig alias ` +
250
+ `like '~/app/client/file.ts' — the build resolves it, and every runtime (including ` +
251
+ `Workers, which cannot resolve paths) matches it in the asset manifest.`
252
+ );
253
+ }
254
+
255
+ const identityKey = clientIdentityKey(specifier);
256
+ const identityFromPath = identityKey !== specifier;
257
+ const hash = assetHashFn(identityKey);
177
258
  const esmName = `client-${hash}`;
178
- const publicPath = resolveImport(esmName) ?? `${JS_PUBLIC_PATH}/${esmName}.js`;
179
-
180
- let exports = '* as _module';
181
- let fnArgs = '_module';
182
- const sourcePath = existsSync(absPath) ? absPath : null;
183
- if (sourcePath) {
184
- const source = readFileSync(sourcePath, 'utf-8');
185
- const discovered = discoverClientExports(source);
186
- exports = discovered.exports;
187
- fnArgs = discovered.fnArgs;
259
+
260
+ if (identityFromPath) {
261
+ getPathIdentityClientJS().push({ modulePath: modulePathResolved, identityKey });
262
+ }
263
+
264
+ let absPath: string | undefined;
265
+ let discovered: { exports: string; fnArgs: string } | undefined;
266
+
267
+ // Both are lazy: a Worker renders script tags without a filesystem, and
268
+ // resolving there would need `import.meta.resolve`, which workerd may reject.
269
+ function currentAbsPath(): string {
270
+ return (absPath ??= resolveClientAbsPath(specifier));
271
+ }
272
+ function currentExports(): { exports: string; fnArgs: string } {
273
+ if (!discovered) {
274
+ const source = readClientSource(currentAbsPath());
275
+ discovered = source
276
+ ? discoverClientExports(source)
277
+ : { exports: '* as _module', fnArgs: '_module' };
278
+ }
279
+ return discovered;
188
280
  }
189
281
 
190
- getClientJSRegistry().set(esmName, {
282
+ const entry: ClientJSEntry = {
191
283
  modulePath: modulePathResolved,
192
- absPath,
284
+ get absPath() {
285
+ return currentAbsPath();
286
+ },
287
+ identityKey,
288
+ identityFromPath,
193
289
  assetHash: hash,
194
290
  esmName,
195
- publicPath,
196
- exports,
197
- fnArgs,
291
+ get publicPath() {
292
+ return clientPublicPath(esmName);
293
+ },
294
+ get exports() {
295
+ return currentExports().exports;
296
+ },
297
+ get fnArgs() {
298
+ return currentExports().fnArgs;
299
+ },
198
300
  type,
199
- });
200
- registerImport(esmName, publicPath);
301
+ };
302
+ getClientJSRegistry().set(esmName, entry);
303
+ registerImport(esmName, entry.publicPath);
201
304
 
202
305
  return {
203
306
  assetHash: hash,
204
307
  esmName,
205
- publicPath,
308
+ get publicPath() {
309
+ return clientPublicPath(esmName);
310
+ },
206
311
  renderScriptTag: (loadScript) => {
207
312
  if (type === 'iife') {
208
- return html`<script src="${publicPath}"></script>`;
313
+ return html`<script src="${clientPublicPath(esmName)}"></script>`;
209
314
  }
210
315
 
211
316
  const t = typeof loadScript;
317
+ const { exports, fnArgs } = currentExports();
212
318
 
213
319
  if (t === 'string') {
214
320
  return html`
@@ -238,9 +344,12 @@ function registerClientJS(
238
344
 
239
345
  /**
240
346
  * 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')`.
347
+ * Prefer a package export, tsconfig alias, or project-root path
348
+ * (`@scope/pkg/file.ts`, `~/app/client/foo.ts`, `app/client/foo.ts`) — those need no
349
+ * filesystem lookup, so Vite, Node, and Workers all derive the same public URL.
350
+ * Vite/esbuild content-hash the emitted filename; `publicPath` reads that URL from
351
+ * the asset manifest. Relative `./` / `../` paths must be resolved at the call site
352
+ * with `import.meta.resolve('./file.ts')`.
244
353
  */
245
354
  export async function buildClientJS(
246
355
  modulePathResolved: string,
@@ -249,12 +358,14 @@ export async function buildClientJS(
249
358
  return registerClientJS(modulePathResolved, options);
250
359
  }
251
360
 
361
+ // Registered through the same public API an app uses. Package-export specifiers keep
362
+ // module load free of `import.meta.resolve`, which workerd rejects after bundling.
252
363
  export const streamingClient = registerClientJS(
253
- import.meta.resolve('./_hs/hyperspan-streaming.client.ts'),
364
+ '@hyperspan/framework/client/_hs/hyperspan-streaming.client.ts',
254
365
  { type: 'iife' }
255
366
  );
256
367
  export const actionsClient = registerClientJS(
257
- import.meta.resolve('./_hs/hyperspan-actions.client.ts')
368
+ '@hyperspan/framework/client/_hs/hyperspan-actions.client.ts'
258
369
  );
259
370
 
260
371
  /**
@@ -6,52 +6,61 @@ export type AssetManifest = {
6
6
  clients: Record<string, string>;
7
7
  };
8
8
 
9
- let _manifest: AssetManifest = {
10
- imports: {},
11
- css: {},
12
- clients: {},
13
- };
9
+ const MANIFEST_KEY = Symbol.for('@hyperspan/asset-manifest');
10
+
11
+ function sharedManifest(): AssetManifest {
12
+ const globalStore = globalThis as typeof globalThis & {
13
+ [MANIFEST_KEY]?: AssetManifest;
14
+ };
15
+ if (!globalStore[MANIFEST_KEY]) {
16
+ globalStore[MANIFEST_KEY] = { imports: {}, css: {}, clients: {} };
17
+ }
18
+ return globalStore[MANIFEST_KEY];
19
+ }
14
20
 
15
21
  /**
16
22
  * Set the build-time asset manifest (called by Vite plugin or build step).
17
23
  */
18
24
  export function setAssetManifest(manifest: AssetManifest): void {
19
- _manifest = manifest;
25
+ const store = sharedManifest();
26
+ store.imports = { ...(manifest.imports ?? {}) };
27
+ store.css = { ...(manifest.css ?? {}) };
28
+ store.clients = { ...(manifest.clients ?? {}) };
20
29
  }
21
30
 
22
31
  /**
23
32
  * Get the current asset manifest.
24
33
  */
25
34
  export function getAssetManifest(): AssetManifest {
26
- return _manifest;
35
+ return sharedManifest();
27
36
  }
28
37
 
29
38
  /**
30
39
  * Resolve a module import name to its public URL.
31
40
  */
32
41
  export function resolveImport(name: string): string | undefined {
33
- return _manifest.imports[name];
42
+ return sharedManifest().imports[name];
34
43
  }
35
44
 
36
45
  /**
37
46
  * Get CSS files for a route path.
38
47
  */
39
48
  export function getRouteCss(path: string): string[] {
40
- return _manifest.css[path] ?? [];
49
+ return sharedManifest().css[path] ?? [];
41
50
  }
42
51
 
43
52
  /**
44
53
  * Build import map object for layout script tags.
45
54
  */
46
55
  export function getImportMap(): Record<string, string> {
47
- return { ..._manifest.imports };
56
+ return { ...sharedManifest().imports };
48
57
  }
49
58
 
50
59
  /**
51
60
  * Register a client module URL at runtime (dev fallback).
52
61
  */
53
62
  export function registerImport(name: string, publicPath: string): void {
54
- _manifest.imports[name] = publicPath;
63
+ sharedManifest().imports[name] = publicPath;
55
64
  }
56
65
 
57
66
  export type ClientJSBuildResult = HS.ClientJSBuildResult;
@@ -63,7 +72,7 @@ export function getClientJSFromManifest(
63
72
  moduleId: string,
64
73
  _exportNames = 'default'
65
74
  ): ClientJSBuildResult {
66
- const publicPath = _manifest.clients[moduleId];
75
+ const publicPath = sharedManifest().clients[moduleId];
67
76
  if (!publicPath) {
68
77
  throw new Error(
69
78
  `[Hyperspan] Client module "${moduleId}" not found in asset manifest. Run hyperspan build or start dev server.`
@@ -0,0 +1,14 @@
1
+ /** Dev-only platform bindings (D1, KV, etc.) shared across Vite SSR modules. */
2
+ let devBindings: unknown;
3
+
4
+ export function setDevBindings(env: unknown): void {
5
+ devBindings = env;
6
+ }
7
+
8
+ export function getDevBindings(): unknown {
9
+ return devBindings;
10
+ }
11
+
12
+ export function clearDevBindingsForTests(): void {
13
+ devBindings = undefined;
14
+ }
@@ -73,6 +73,29 @@ describe('createFetchHandler', () => {
73
73
  expect(await response.text()).toBe('abc');
74
74
  });
75
75
 
76
+ test('preserves Cookie on the request passed to the route', async () => {
77
+ const server = await createServer({
78
+ appDir: './app',
79
+ publicDir: './public',
80
+ plugins: [],
81
+ });
82
+
83
+ const route = createRoute({ path: '/session' }).get((c: HS.Context) =>
84
+ c.res.text(c.req.raw.headers.get('cookie') ?? '')
85
+ );
86
+ server._routes.push(route);
87
+
88
+ const headers = new Headers({ cookie: 'session=abc' });
89
+ const request = new Request('http://localhost/session', { headers });
90
+ // Node's Request constructor strips Cookie; re-attach like Vite's nodeToWebRequest.
91
+ Object.defineProperty(request, 'headers', { value: headers });
92
+
93
+ const fetch = createFetchHandler(server);
94
+ const response = await fetch(request);
95
+
96
+ expect(await response.text()).toBe('session=abc');
97
+ });
98
+
76
99
  test('redirects trailing slashes', async () => {
77
100
  const server = await createServer({
78
101
  appDir: './app',
@@ -141,10 +141,11 @@ export function createFetchHandler(
141
141
 
142
142
  const matched = matchRoute(compiledRoutes, pathname, method);
143
143
  if (matched) {
144
- const reqWithParams = new Request(request);
145
- (reqWithParams as Request & { params?: Record<string, string | undefined> }).params =
144
+ // Attach params on the original request. `new Request(request)` clones
145
+ // through Node's Request constructor, which strips Cookie/Origin/Host.
146
+ (request as Request & { params?: Record<string, string | undefined> }).params =
146
147
  matched.params;
147
- return matched.route.fetch(reqWithParams);
148
+ return matched.route.fetch(request);
148
149
  }
149
150
 
150
151
  if (options.onNotMatched) {
@@ -0,0 +1,13 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { html, placeholder, render, type HSHtml } from './html';
3
+
4
+ describe('@hyperspan/framework/html', () => {
5
+ test('re-exports html helpers and types from @hyperspan/html', () => {
6
+ const tmpl: HSHtml = html`<p>
7
+ ${placeholder(html`<span>loading</span>`, Promise.resolve('ok'))}
8
+ </p>`;
9
+
10
+ expect(render(tmpl)).toContain('<p>');
11
+ expect(render(tmpl)).toContain('loading');
12
+ });
13
+ });
package/src/html.ts ADDED
@@ -0,0 +1 @@
1
+ export * from '@hyperspan/html';
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export {
3
3
  createContext,
4
4
  createRoute,
5
5
  createServer,
6
+ initServerRoutes,
6
7
  getRunnableRoute,
7
8
  StreamResponse,
8
9
  IS_PROD,
@@ -34,4 +35,6 @@ export type {
34
35
  AdapterAfterBuildContext,
35
36
  DeployEntry,
36
37
  DeployEntryContext,
38
+ ServerEntrySlots,
39
+ ServerEntryTemplate,
37
40
  } from './types';