@hyperspan/framework 2.0.0-alpha.6 → 2.0.0

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",
4
4
  "description": "Hyperspan Web Framework",
5
5
  "type": "module",
6
6
  "main": "src/server.ts",
@@ -8,7 +8,7 @@
8
8
  "public": true,
9
9
  "publishConfig": {
10
10
  "access": "public",
11
- "tag": "alpha"
11
+ "tag": "latest"
12
12
  },
13
13
  "exports": {
14
14
  ".": {
@@ -35,6 +35,10 @@
35
35
  "types": "./src/layout.ts",
36
36
  "default": "./src/layout.ts"
37
37
  },
38
+ "./dev-bindings": {
39
+ "types": "./src/dev-bindings.ts",
40
+ "default": "./src/dev-bindings.ts"
41
+ },
38
42
  "./client/css": {
39
43
  "types": "./src/client/css.ts",
40
44
  "default": "./src/client/css.ts"
@@ -51,6 +55,10 @@
51
55
  "types": "./src/actions.ts",
52
56
  "default": "./src/actions.ts"
53
57
  },
58
+ "./html": {
59
+ "types": "./src/html.ts",
60
+ "default": "./src/html.ts"
61
+ },
54
62
  "./ssr/install-server-dom-mock": {
55
63
  "types": "./src/ssr/install-server-dom-mock.ts",
56
64
  "default": "./src/ssr/install-server-dom-mock.ts"
@@ -97,10 +105,10 @@
97
105
  "@types/node": "^24.10.0",
98
106
  "prettier": "^3.5.2",
99
107
  "typescript": "^5.9.3",
100
- "vitest": "^3.2.4"
108
+ "vitest": "^4.1.10"
101
109
  },
102
110
  "dependencies": {
103
- "@hyperspan/html": "^2.0.0-alpha.4",
111
+ "@hyperspan/html": "^2.0.0",
104
112
  "debug": "^4.4.3",
105
113
  "isbot": "^5.1.32",
106
114
  "zod": "^4.4.3"
@@ -1,7 +1,7 @@
1
1
  import { test, expect, describe } from 'vitest';
2
2
  import { createAction } from './actions';
3
3
  import { createRoute } from './server';
4
- import { html, render, placeholder, type HSHtml } from '@hyperspan/html';
4
+ import { html, render, placeholder, type HSHtml } from './html';
5
5
  import { createContext } from './server';
6
6
  import * as z from 'zod';
7
7
 
package/src/actions.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { html } from '@hyperspan/html';
1
+ import { html } from './html';
2
2
  import { createRoute, HTTPResponseException, returnHTMLResponse } from './server';
3
3
  import * as z from 'zod';
4
4
  import type { Hyperspan as HS } from './types';
@@ -4,11 +4,7 @@ import { createConfig } from './server';
4
4
 
5
5
  const cloudflare: Adapter = {
6
6
  name: 'cloudflare',
7
- createEntry() {
8
- return {
9
- fetch: async () => new Response('ok'),
10
- };
11
- },
7
+ renderServerEntry: (template) => template(),
12
8
  };
13
9
 
14
10
  describe('createConfig deployAdapter', () => {
@@ -1,14 +1,19 @@
1
1
  import { describe, expect, test, beforeEach } from 'vitest';
2
- import { mkdtempSync, writeFileSync } 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,
9
12
  extractExports,
10
13
  getClientJSEntries,
14
+ registerPathAliases,
11
15
  resetClientJSEntriesForTests,
16
+ streamingClient,
12
17
  } from './js';
13
18
 
14
19
  describe('buildClientJS', () => {
@@ -16,6 +21,7 @@ describe('buildClientJS', () => {
16
21
 
17
22
  beforeEach(() => {
18
23
  resetClientJSEntriesForTests();
24
+ setAssetManifest({ imports: {}, css: {}, clients: {} });
19
25
  const dir = mkdtempSync(join(tmpdir(), 'hs-client-'));
20
26
  clientFile = join(dir, 'picker.ts');
21
27
  writeFileSync(
@@ -27,7 +33,7 @@ describe('buildClientJS', () => {
27
33
  );
28
34
  });
29
35
 
30
- test('registers entry and returns stable esmName from path hash', async () => {
36
+ test('registers entry with a stable path identity', async () => {
31
37
  const result = await buildClientJS(clientFile);
32
38
 
33
39
  expect(result.esmName).toMatch(/^client-[0-9a-f]{16}$/);
@@ -36,11 +42,37 @@ describe('buildClientJS', () => {
36
42
  expect(getClientJSEntries()[0].absPath).toBe(clientFile);
37
43
  });
38
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
+
39
71
  test('renderScriptTag with no loader uses import map key', async () => {
40
72
  const result = await buildClientJS(clientFile);
41
73
  const tag = render(result.renderScriptTag());
42
74
 
43
- expect(tag).toContain(`import "${result.esmName}"`);
75
+ expect(tag).toContain(`import '${result.esmName}'`);
44
76
  expect(tag).toContain(`data-source-id="${result.assetHash}"`);
45
77
  });
46
78
 
@@ -52,7 +84,7 @@ describe('buildClientJS', () => {
52
84
  })
53
85
  );
54
86
 
55
- expect(tag).toContain(`import {mountPicker} from "${result.esmName}"`);
87
+ expect(tag).toContain(`import {mountPicker} from '${result.esmName}'`);
56
88
  expect(tag).toContain('mountPicker()');
57
89
  });
58
90
 
@@ -63,15 +95,52 @@ describe('buildClientJS', () => {
63
95
  expect(tag).toContain('({ mountPicker }) => mountPicker()');
64
96
  });
65
97
 
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' }
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'"
70
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 () => {
125
+ const result = await buildClientJS(import.meta.resolve('./_hs/hyperspan-streaming.client.ts'), {
126
+ type: 'iife',
127
+ });
71
128
 
72
129
  expect(result.publicPath).toMatch(/^\/_hs\/js\/client-[0-9a-f]{16}\.js$/);
73
130
  expect(getClientJSEntries()[0].type).toBe('iife');
131
+ expect(existsSync(getClientJSEntries()[0].absPath)).toBe(true);
74
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);
139
+ });
140
+
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/);
75
144
  });
76
145
 
77
146
  test('type iife renders a classic script tag', async () => {
@@ -104,6 +173,39 @@ describe('buildClientJS', () => {
104
173
  process.chdir(cwd);
105
174
  }
106
175
  });
176
+
177
+ test('tsconfig aliases resolve inside buildClientJS without import.meta.resolve', async () => {
178
+ const dir = mkdtempSync(join(tmpdir(), 'hs-client-alias-'));
179
+ const { mkdirSync } = await import('node:fs');
180
+ mkdirSync(join(dir, 'app/client'), { recursive: true });
181
+ const absPath = join(dir, 'app/client/stats.ts');
182
+ writeFileSync(absPath, `export function mountStats() {}\n`);
183
+
184
+ registerPathAliases({ '~/': `${dir}/`, '~': dir });
185
+
186
+ const result = await buildClientJS('~/app/client/stats.ts');
187
+ expect(getClientJSEntries()[0].absPath.replace(/^\/private/, '')).toBe(
188
+ absPath.replace(/^\/private/, '')
189
+ );
190
+ expect(getClientJSEntries()[0].modulePath).toBe('~/app/client/stats.ts');
191
+
192
+ resetClientJSEntriesForTests();
193
+ registerPathAliases({ '~/': `${dir}/`, '~': dir });
194
+ const second = await buildClientJS('~/app/client/stats.ts');
195
+ expect(second.esmName).toBe(result.esmName);
196
+ });
197
+
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);
208
+ });
107
209
  });
108
210
 
109
211
  describe('extractExports', () => {
package/src/client/js.ts CHANGED
@@ -1,7 +1,7 @@
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
- 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';
@@ -19,7 +19,19 @@ export type BuildClientJSOptions = {
19
19
  };
20
20
 
21
21
  export type ClientJSEntry = {
22
+ /** Original path/specifier passed to `buildClientJS`. */
23
+ modulePath: string;
24
+ /** Resolved file path. Lazy — only a build/dev tool with a filesystem reads this. */
22
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). */
23
35
  assetHash: string;
24
36
  esmName: string;
25
37
  publicPath: string;
@@ -28,7 +40,58 @@ export type ClientJSEntry = {
28
40
  type: ClientJSType;
29
41
  };
30
42
 
43
+ export type ClientJSPathIdentity = {
44
+ modulePath: string;
45
+ identityKey: string;
46
+ };
47
+
31
48
  const CLIENT_JS_REGISTRY = Symbol.for('@hyperspan/client-js-entries');
49
+ const CLIENT_JS_PATH_IDENTITIES = Symbol.for('@hyperspan/client-js-path-identities');
50
+ const PATH_ALIASES = Symbol.for('@hyperspan/path-aliases');
51
+
52
+ function getPathAliases(): Record<string, string> {
53
+ const globalAliases = globalThis as {
54
+ [PATH_ALIASES]?: Record<string, string>;
55
+ };
56
+ if (!globalAliases[PATH_ALIASES]) {
57
+ globalAliases[PATH_ALIASES] = {};
58
+ }
59
+ return globalAliases[PATH_ALIASES];
60
+ }
61
+
62
+ /** Register tsconfig path aliases (`~/` → project root). Called by the Vite plugin. */
63
+ export function registerPathAliases(aliases: Record<string, string>): void {
64
+ Object.assign(getPathAliases(), aliases);
65
+ }
66
+
67
+ function resolveWithAliases(specifier: string): string | undefined {
68
+ const aliases = getPathAliases();
69
+ const keys = Object.keys(aliases).sort((a, b) => b.length - a.length);
70
+ for (const key of keys) {
71
+ if (specifier === key || specifier.startsWith(key)) {
72
+ const rest = specifier.slice(key.length).replace(/^\//, '');
73
+ return join(aliases[key], rest);
74
+ }
75
+ }
76
+ return undefined;
77
+ }
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
+ }
32
95
 
33
96
  function getClientJSRegistry(): Map<string, ClientJSEntry> {
34
97
  const globalRegistry = globalThis as {
@@ -69,46 +132,92 @@ export const JS_IMPORT_MAP = {
69
132
  };
70
133
 
71
134
  /**
72
- * Resolve import.meta.resolve() / file URL / absolute path to a filesystem path.
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.
73
138
  */
74
- export function resolveClientModulePath(modulePathResolved: string): string {
75
- if (modulePathResolved.startsWith('file://')) {
76
- return fileURLToPath(modulePathResolved);
139
+ export function stableClientSourceKey(absPath: string): string {
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
+ }
146
+ if (typeof process !== 'undefined' && typeof process.cwd === 'function') {
147
+ const rel = relative(process.cwd(), absPath).replace(/\\/g, '/');
148
+ if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
149
+ return rel;
150
+ }
77
151
  }
78
- return modulePathResolved;
152
+ return normalized;
79
153
  }
80
154
 
81
- /**
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.
85
- */
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
- }
155
+ function toFilePath(modulePath: string): string {
156
+ return modulePath.startsWith('file://') ? fileURLToPath(modulePath) : modulePath;
157
+ }
158
+
159
+ function tryResolveToFile(specifier: string): string | undefined {
160
+ try {
161
+ if (typeof import.meta.resolve !== 'function') {
162
+ return undefined;
99
163
  }
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.
164
+ const resolved = import.meta.resolve(specifier);
165
+ if (typeof resolved === 'string' && (resolved.startsWith('file://') || isAbsolute(resolved))) {
166
+ return toFilePath(resolved);
107
167
  }
108
- return { hashKey, absPath: hashKey };
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
+
178
+ /**
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.
185
+ */
186
+ function clientIdentityKey(specifier: string): string {
187
+ if (specifier.startsWith('file://') || isAbsolute(specifier)) {
188
+ return stableClientSourceKey(toFilePath(specifier));
189
+ }
190
+ return specifier;
191
+ }
192
+
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);
200
+ }
201
+
202
+ const aliased = resolveWithAliases(specifier);
203
+ if (aliased) {
204
+ return aliased;
109
205
  }
110
206
 
111
- return { hashKey: resolved, absPath: resolved };
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;
112
221
  }
113
222
 
114
223
  export function getClientJSEntries(): ClientJSEntry[] {
@@ -121,10 +230,11 @@ export function getClientJSEntryByEsmName(esmName: string): ClientJSEntry | unde
121
230
 
122
231
  export function resetClientJSEntriesForTests(): void {
123
232
  getClientJSRegistry().clear();
124
- }
125
-
126
- function registerClientJSEntry(entry: ClientJSEntry): void {
127
- getClientJSRegistry().set(entry.esmName, entry);
233
+ getPathIdentityClientJS().length = 0;
234
+ const aliases = getPathAliases();
235
+ for (const key of Object.keys(aliases)) {
236
+ delete aliases[key];
237
+ }
128
238
  }
129
239
 
130
240
  function registerClientJS(
@@ -132,34 +242,79 @@ function registerClientJS(
132
242
  options: BuildClientJSOptions = {}
133
243
  ): HS.ClientJSBuildResult {
134
244
  const type = options.type ?? 'module';
135
- const { hashKey, absPath } = resolveClientModulePaths(modulePathResolved);
136
- 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);
137
258
  const esmName = `client-${hash}`;
138
- const publicPath = resolveImport(esmName) ?? `${JS_PUBLIC_PATH}/${esmName}.js`;
139
-
140
- let exports = '* as _module';
141
- let fnArgs = '_module';
142
- const sourcePath = existsSync(absPath) ? absPath : null;
143
- if (sourcePath) {
144
- const source = readFileSync(sourcePath, 'utf-8');
145
- const discovered = discoverClientExports(source);
146
- exports = discovered.exports;
147
- 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;
148
280
  }
149
281
 
150
- registerClientJSEntry({ absPath, assetHash: hash, esmName, publicPath, exports, fnArgs, type });
151
- registerImport(esmName, publicPath);
282
+ const entry: ClientJSEntry = {
283
+ modulePath: modulePathResolved,
284
+ get absPath() {
285
+ return currentAbsPath();
286
+ },
287
+ identityKey,
288
+ identityFromPath,
289
+ assetHash: hash,
290
+ esmName,
291
+ get publicPath() {
292
+ return clientPublicPath(esmName);
293
+ },
294
+ get exports() {
295
+ return currentExports().exports;
296
+ },
297
+ get fnArgs() {
298
+ return currentExports().fnArgs;
299
+ },
300
+ type,
301
+ };
302
+ getClientJSRegistry().set(esmName, entry);
303
+ registerImport(esmName, entry.publicPath);
152
304
 
153
305
  return {
154
306
  assetHash: hash,
155
307
  esmName,
156
- publicPath,
308
+ get publicPath() {
309
+ return clientPublicPath(esmName);
310
+ },
157
311
  renderScriptTag: (loadScript) => {
158
312
  if (type === 'iife') {
159
- return html`<script src="${publicPath}"></script>`;
313
+ return html`<script src="${clientPublicPath(esmName)}"></script>`;
160
314
  }
161
315
 
162
316
  const t = typeof loadScript;
317
+ const { exports, fnArgs } = currentExports();
163
318
 
164
319
  if (t === 'string') {
165
320
  return html`
@@ -188,10 +343,13 @@ function registerClientJS(
188
343
  }
189
344
 
190
345
  /**
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.
346
+ * Register a client JS module for Vite to bundle.
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')`.
195
353
  */
196
354
  export async function buildClientJS(
197
355
  modulePathResolved: string,
@@ -200,7 +358,8 @@ export async function buildClientJS(
200
358
  return registerClientJS(modulePathResolved, options);
201
359
  }
202
360
 
203
- /** Same `buildClientJS` path an app would use for a package export. */
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.
204
363
  export const streamingClient = registerClientJS(
205
364
  '@hyperspan/framework/client/_hs/hyperspan-streaming.client.ts',
206
365
  { type: 'iife' }
@@ -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,
@@ -19,7 +20,7 @@ export {
19
20
  getImportMap,
20
21
  registerImport,
21
22
  } from './client/manifest';
22
- export { buildClientJS } from './client/js';
23
+ export { buildClientJS, registerPathAliases } from './client/js';
23
24
  export type { BuildClientJSOptions, ClientJSType } from './client/js';
24
25
  export { registerRouteModule, registerRouteModules } from './register-routes';
25
26
  export type { RouteModuleEntry } from './register-routes';
@@ -34,4 +35,6 @@ export type {
34
35
  AdapterAfterBuildContext,
35
36
  DeployEntry,
36
37
  DeployEntryContext,
38
+ ServerEntrySlots,
39
+ ServerEntryTemplate,
37
40
  } from './types';
package/src/island.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { html } from '@hyperspan/html';
1
+ import { html } from './html';
2
2
 
3
3
  type IslandComponent = {
4
4
  __HS_ISLAND?: {
@@ -1,5 +1,5 @@
1
1
  import { test, expect } from 'vitest';
2
- import { render } from '@hyperspan/html';
2
+ import { render } from './html';
3
3
  import { hyperspanScriptTags } from './layout';
4
4
  import { streamingClient } from './client/js';
5
5
 
package/src/layout.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { html } from '@hyperspan/html';
1
+ import { html } from './html';
2
2
  import { CSS_PUBLIC_PATH } from './client/css';
3
3
  import { getImportMap, getRouteCss } from './client/manifest';
4
4
  import { actionsClient, streamingClient } from './client/js';
@@ -1,7 +1,7 @@
1
1
  import { test, expect, describe } from 'vitest';
2
- import { createRoute, createServer, createContext } from './server';
2
+ import { createRoute, createServer, createContext, initServerRoutes } from './server';
3
3
  import { createAction } from './actions';
4
- import { html, placeholder } from '@hyperspan/html';
4
+ import { html, placeholder } from './html';
5
5
  import type { Hyperspan as HS } from './types';
6
6
 
7
7
  test('route fetch() returns a Response', async () => {
@@ -92,6 +92,25 @@ test('server returns a route with a POST request', async () => {
92
92
  expect(await response.text()).toBe('<h1>POST /users</h1>');
93
93
  });
94
94
 
95
+ test('POST HTML response does not inherit request Content-Length', async () => {
96
+ const route = createRoute().post((context: HS.Context) => {
97
+ return context.res.html('<h1>hello world this is longer than the form body</h1>');
98
+ });
99
+ const body = 'email=a@b.com&password=secret';
100
+ const request = new Request('http://localhost:3000/', {
101
+ method: 'POST',
102
+ headers: {
103
+ 'content-type': 'application/x-www-form-urlencoded',
104
+ 'content-length': String(body.length),
105
+ },
106
+ body,
107
+ });
108
+ const response = await route.fetch(request);
109
+ const text = await response.text();
110
+ expect(text).toBe('<h1>hello world this is longer than the form body</h1>');
111
+ expect(Number(response.headers.get('content-length') ?? text.length)).toBe(text.length);
112
+ });
113
+
95
114
  test('server returns a route with a ALL request', async () => {
96
115
  const server = await createServer({
97
116
  appDir: './app',
@@ -566,3 +585,31 @@ describe('when streaming is disabled', () => {
566
585
  expect(text).not.toContain('hs:loading');
567
586
  });
568
587
  });
588
+
589
+ test('initServerRoutes runs beforeRoutesAdded, then addRoutes, then afterRoutesAdded', async () => {
590
+ const order: string[] = [];
591
+ const server = await createServer({
592
+ appDir: './app',
593
+ publicDir: './public',
594
+ plugins: [],
595
+ });
596
+
597
+ await initServerRoutes(
598
+ server,
599
+ {
600
+ beforeRoutesAdded(s) {
601
+ order.push('before');
602
+ s.use(async (_c, next) => next());
603
+ },
604
+ afterRoutesAdded() {
605
+ order.push('after');
606
+ },
607
+ },
608
+ () => {
609
+ order.push('routes');
610
+ }
611
+ );
612
+
613
+ expect(order).toEqual(['before', 'routes', 'after']);
614
+ expect(server._middleware['*']).toHaveLength(1);
615
+ });
package/src/server.ts CHANGED
@@ -1,13 +1,5 @@
1
1
  import './ssr/install-server-dom-mock';
2
- import {
3
- HSHtml,
4
- html,
5
- isHSHtml,
6
- renderStream,
7
- renderAsync,
8
- render,
9
- _typeOf,
10
- } from '@hyperspan/html';
2
+ import { HSHtml, html, isHSHtml, renderStream, renderAsync, render, _typeOf } from './html';
11
3
  import { isbot } from 'isbot';
12
4
  import { executeMiddleware } from './middleware';
13
5
  import { buildUrl, parsePath, removeUndefined } from './utils';
@@ -75,7 +67,8 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
75
67
  const url = new URL(req.url);
76
68
  const query = new URLSearchParams(url.search);
77
69
  const method = req.method.toUpperCase();
78
- const headers = new Headers(req.headers);
70
+ const requestHeaders = new Headers(req.headers);
71
+ const responseHeaders = new Headers();
79
72
  const path = route?._path() || '/';
80
73
  const requestParams = (req as Request & { params?: Record<string, string | undefined> }).params;
81
74
  const params: Record<string, string | undefined> = Object.assign(
@@ -95,12 +88,21 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
95
88
  // Status override for the response. Will use if set. (e.g. c.res.status = 400)
96
89
  let status: number | undefined = undefined;
97
90
 
91
+ const copyHeaders = (source: Headers, target: Headers) => {
92
+ source.forEach((value, key) => {
93
+ if (key.toLowerCase() === 'set-cookie') return;
94
+ target.set(key, value);
95
+ });
96
+ const cookies = typeof source.getSetCookie === 'function' ? source.getSetCookie() : [];
97
+ for (const cookie of cookies) {
98
+ target.append('Set-Cookie', cookie);
99
+ }
100
+ };
101
+
98
102
  const merge = async (response: Response) => {
99
- // Convert headers to plain objects and merge (response headers override context headers)
100
- const mergedHeaders = {
101
- ...Object.fromEntries(headers.entries()),
102
- ...Object.fromEntries(response.headers.entries()),
103
- };
103
+ const mergedHeaders = new Headers();
104
+ copyHeaders(responseHeaders, mergedHeaders);
105
+ copyHeaders(response.headers, mergedHeaders);
104
106
 
105
107
  return new Response(await response.text(), {
106
108
  status: context.res.status ?? response.status,
@@ -124,7 +126,7 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
124
126
  raw: req,
125
127
  url,
126
128
  method,
127
- headers,
129
+ headers: requestHeaders,
128
130
  query,
129
131
  cookies: new Cookies(req),
130
132
  async text() {
@@ -141,8 +143,8 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
141
143
  },
142
144
  },
143
145
  res: {
144
- cookies: new Cookies(req, headers),
145
- headers,
146
+ cookies: new Cookies(req, responseHeaders),
147
+ headers: responseHeaders,
146
148
  status,
147
149
  html: (html: string, options?: ResponseInit) =>
148
150
  merge(
@@ -504,6 +506,20 @@ export async function createServer(config: HS.Config = {} as HS.Config): Promise
504
506
  return api;
505
507
  }
506
508
 
509
+ /**
510
+ * Shared route lifecycle for Vite dev, production entries, and `hyperspan start`.
511
+ * Always: `beforeRoutesAdded` → add routes → `afterRoutesAdded`.
512
+ */
513
+ export async function initServerRoutes(
514
+ server: HS.Server,
515
+ config: Pick<HS.Config, 'beforeRoutesAdded' | 'afterRoutesAdded'>,
516
+ addRoutes: (server: HS.Server) => void | Promise<void>
517
+ ): Promise<void> {
518
+ await config.beforeRoutesAdded?.(server);
519
+ await addRoutes(server);
520
+ await config.afterRoutesAdded?.(server);
521
+ }
522
+
507
523
  /**
508
524
  * Checks if a response is HTML content
509
525
  */
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { HSHtml } from '@hyperspan/html';
1
+ import { HSHtml } from './html';
2
2
  import * as z from 'zod';
3
3
 
4
4
  /**
@@ -69,8 +69,12 @@ export namespace Hyperspan {
69
69
  outDir: string;
70
70
  };
71
71
 
72
+ export type ServerCreateContext = {
73
+ env: unknown;
74
+ };
75
+
72
76
  export type DeployEntryContext = {
73
- createHyperspanServer: () => Promise<Server>;
77
+ createHyperspanServer: (ctx: ServerCreateContext) => Promise<Server>;
74
78
  config: Config;
75
79
  };
76
80
 
@@ -79,6 +83,18 @@ export namespace Hyperspan {
79
83
  fetch?(request: Request, env?: unknown, ctx?: unknown): Response | Promise<Response>;
80
84
  };
81
85
 
86
+ /** Optional snippets interpolated into generated `dist/server.ts`. Null/omitted slots emit nothing. */
87
+ export type ServerEntrySlots = {
88
+ beforeFileContent?: string | null;
89
+ beforeCreateServer?: string | null;
90
+ afterCreateServer?: string | null;
91
+ beforeRoutes?: string | null;
92
+ afterRoutes?: string | null;
93
+ afterFileContent?: string | null;
94
+ };
95
+
96
+ export type ServerEntryTemplate = (slots?: ServerEntrySlots) => string;
97
+
82
98
  export type Adapter = {
83
99
  /** Diagnostic name, e.g. `node`, `cloudflare`. */
84
100
  name: string;
@@ -88,18 +104,17 @@ export namespace Hyperspan {
88
104
  */
89
105
  devModule?: string;
90
106
  resolveDevEnv?(root: string): Promise<unknown>;
91
- /** Platform entry (`start` and/or `fetch`). Called from generated `dist/server.ts`. */
92
- createEntry(ctx: DeployEntryContext): DeployEntry;
107
+ /**
108
+ * Render generated `dist/server.ts` from the shared bootstrap template.
109
+ * Fill slots (especially `afterFileContent`) for the platform entry.
110
+ */
111
+ renderServerEntry(template: ServerEntryTemplate): string | Promise<string>;
93
112
  afterBuild?(ctx: AdapterAfterBuildContext): void | Promise<void>;
94
113
  };
95
114
 
96
115
  /** Runtime adapter object (`nodeAdapter()`, `cloudflareAdapter()`, …). */
97
116
  export type DeployAdapter = Adapter;
98
117
 
99
- export type ServerCreateContext = {
100
- env: unknown;
101
- };
102
-
103
118
  export type Config = {
104
119
  appDir: string;
105
120
  publicDir: string;
@@ -118,8 +133,8 @@ export namespace Hyperspan {
118
133
  */
119
134
  beforeServerCreate?: (ctx: ServerCreateContext) => void | Promise<void>;
120
135
  // For customizing the routes and adding your own...
121
- beforeRoutesAdded?: (server: Hyperspan.Server) => void;
122
- afterRoutesAdded?: (server: Hyperspan.Server) => void;
136
+ beforeRoutesAdded?: (server: Hyperspan.Server) => void | Promise<void>;
137
+ afterRoutesAdded?: (server: Hyperspan.Server) => void | Promise<void>;
123
138
  responseOptions?: ResponseOptions;
124
139
  };
125
140
 
@@ -415,7 +430,7 @@ export namespace Hyperspan {
415
430
  * Client JS Module = ESM Module + Public Path + Render Script Tag
416
431
  */
417
432
  export type ClientJSBuildResult = {
418
- assetHash: string; // Asset hash of the module path
433
+ assetHash: string; // Identity of the resolved path; production file hash comes from Vite
419
434
  esmName: string; // Filename of the built JavaScript file without the extension
420
435
  publicPath: string; // Full public path of the built JavaScript file
421
436
  /**
@@ -443,6 +458,8 @@ export type Adapter = Hyperspan.Adapter;
443
458
  export type AdapterAfterBuildContext = Hyperspan.AdapterAfterBuildContext;
444
459
  export type DeployEntry = Hyperspan.DeployEntry;
445
460
  export type DeployEntryContext = Hyperspan.DeployEntryContext;
461
+ export type ServerEntrySlots = Hyperspan.ServerEntrySlots;
462
+ export type ServerEntryTemplate = Hyperspan.ServerEntryTemplate;
446
463
 
447
464
  declare global {
448
465
  interface DocumentEventMap {