@hyperspan/framework 2.0.0-alpha.2 → 2.0.0-alpha.20
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 +15 -3
- package/src/actions.test.ts +1 -1
- package/src/actions.ts +1 -1
- package/src/adapter.test.ts +19 -0
- package/src/client/js.test.ts +149 -5
- package/src/client/js.ts +255 -29
- package/src/client/manifest.ts +22 -17
- package/src/dev-bindings.ts +14 -0
- package/src/fetch-handler.test.ts +23 -0
- package/src/fetch-handler.ts +4 -3
- package/src/html.test.ts +13 -0
- package/src/html.ts +1 -0
- package/src/index.ts +16 -2
- package/src/island.ts +1 -1
- package/src/layout.test.ts +12 -0
- package/src/layout.ts +11 -19
- package/src/register-routes.ts +64 -0
- package/src/server.test.ts +49 -2
- package/src/server.ts +37 -18
- package/src/types.ts +79 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspan/framework",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.20",
|
|
4
4
|
"description": "Hyperspan Web Framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/server.ts",
|
|
@@ -27,10 +27,18 @@
|
|
|
27
27
|
"types": "./src/utils.ts",
|
|
28
28
|
"default": "./src/utils.ts"
|
|
29
29
|
},
|
|
30
|
+
"./routes": {
|
|
31
|
+
"types": "./src/register-routes.ts",
|
|
32
|
+
"default": "./src/register-routes.ts"
|
|
33
|
+
},
|
|
30
34
|
"./layout": {
|
|
31
35
|
"types": "./src/layout.ts",
|
|
32
36
|
"default": "./src/layout.ts"
|
|
33
37
|
},
|
|
38
|
+
"./dev-bindings": {
|
|
39
|
+
"types": "./src/dev-bindings.ts",
|
|
40
|
+
"default": "./src/dev-bindings.ts"
|
|
41
|
+
},
|
|
34
42
|
"./client/css": {
|
|
35
43
|
"types": "./src/client/css.ts",
|
|
36
44
|
"default": "./src/client/css.ts"
|
|
@@ -47,6 +55,10 @@
|
|
|
47
55
|
"types": "./src/actions.ts",
|
|
48
56
|
"default": "./src/actions.ts"
|
|
49
57
|
},
|
|
58
|
+
"./html": {
|
|
59
|
+
"types": "./src/html.ts",
|
|
60
|
+
"default": "./src/html.ts"
|
|
61
|
+
},
|
|
50
62
|
"./ssr/install-server-dom-mock": {
|
|
51
63
|
"types": "./src/ssr/install-server-dom-mock.ts",
|
|
52
64
|
"default": "./src/ssr/install-server-dom-mock.ts"
|
|
@@ -93,10 +105,10 @@
|
|
|
93
105
|
"@types/node": "^24.10.0",
|
|
94
106
|
"prettier": "^3.5.2",
|
|
95
107
|
"typescript": "^5.9.3",
|
|
96
|
-
"vitest": "^
|
|
108
|
+
"vitest": "^4.1.10"
|
|
97
109
|
},
|
|
98
110
|
"dependencies": {
|
|
99
|
-
"@hyperspan/html": "^2.0.0-alpha.
|
|
111
|
+
"@hyperspan/html": "^2.0.0-alpha.20",
|
|
100
112
|
"debug": "^4.4.3",
|
|
101
113
|
"isbot": "^5.1.32",
|
|
102
114
|
"zod": "^4.4.3"
|
package/src/actions.test.ts
CHANGED
|
@@ -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 '
|
|
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
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { Adapter } from './types';
|
|
3
|
+
import { createConfig } from './server';
|
|
4
|
+
|
|
5
|
+
const cloudflare: Adapter = {
|
|
6
|
+
name: 'cloudflare',
|
|
7
|
+
renderServerEntry: (template) => template(),
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
describe('createConfig deployAdapter', () => {
|
|
11
|
+
test('defaults to Node when omitted or undefined', () => {
|
|
12
|
+
expect(createConfig({}).deployAdapter.name).toBe('node');
|
|
13
|
+
expect(createConfig({ deployAdapter: undefined }).deployAdapter.name).toBe('node');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('keeps an explicit adapter', () => {
|
|
17
|
+
expect(createConfig({ deployAdapter: cloudflare }).deployAdapter).toBe(cloudflare);
|
|
18
|
+
});
|
|
19
|
+
});
|
package/src/client/js.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
87
|
+
expect(tag).toContain(`import {mountPicker} from '${result.esmName}'`);
|
|
56
88
|
expect(tag).toContain('mountPicker()');
|
|
57
89
|
});
|
|
58
90
|
|
|
@@ -62,6 +94,118 @@ describe('buildClientJS', () => {
|
|
|
62
94
|
|
|
63
95
|
expect(tag).toContain('({ mountPicker }) => mountPicker()');
|
|
64
96
|
});
|
|
97
|
+
|
|
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 () => {
|
|
125
|
+
const result = await buildClientJS(import.meta.resolve('./_hs/hyperspan-streaming.client.ts'), {
|
|
126
|
+
type: 'iife',
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(result.publicPath).toMatch(/^\/_hs\/js\/client-[0-9a-f]{16}\.js$/);
|
|
130
|
+
expect(getClientJSEntries()[0].type).toBe('iife');
|
|
131
|
+
expect(existsSync(getClientJSEntries()[0].absPath)).toBe(true);
|
|
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/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('type iife renders a classic script tag', async () => {
|
|
147
|
+
const result = await buildClientJS(clientFile, { type: 'iife' });
|
|
148
|
+
const tag = render(result.renderScriptTag());
|
|
149
|
+
|
|
150
|
+
expect(tag).toContain(`<script src="${result.publicPath}"`);
|
|
151
|
+
expect(tag).not.toContain('type="module"');
|
|
152
|
+
expect(getClientJSEntries()[0].type).toBe('iife');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('logical app-relative paths hash consistently and resolve on disk when present', async () => {
|
|
156
|
+
const dir = mkdtempSync(join(tmpdir(), 'hs-client-logical-'));
|
|
157
|
+
const { mkdirSync } = await import('node:fs');
|
|
158
|
+
mkdirSync(join(dir, 'app/client'), { recursive: true });
|
|
159
|
+
const logicalPath = join(dir, 'app/client/stats.ts');
|
|
160
|
+
writeFileSync(logicalPath, `export function mountStats() {}\n`);
|
|
161
|
+
|
|
162
|
+
const cwd = process.cwd();
|
|
163
|
+
process.chdir(dir);
|
|
164
|
+
try {
|
|
165
|
+
const first = await buildClientJS('app/client/stats.ts');
|
|
166
|
+
resetClientJSEntriesForTests();
|
|
167
|
+
const second = await buildClientJS('app/client/stats.ts');
|
|
168
|
+
expect(first.esmName).toBe(second.esmName);
|
|
169
|
+
expect(getClientJSEntries()[0].absPath.replace(/^\/private/, '')).toBe(
|
|
170
|
+
logicalPath.replace(/^\/private/, '')
|
|
171
|
+
);
|
|
172
|
+
} finally {
|
|
173
|
+
process.chdir(cwd);
|
|
174
|
+
}
|
|
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
|
+
});
|
|
65
209
|
});
|
|
66
210
|
|
|
67
211
|
describe('extractExports', () => {
|
package/src/client/js.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join, relative } from 'node:path';
|
|
2
3
|
import { fileURLToPath } from 'node:url';
|
|
3
|
-
import { html } from '
|
|
4
|
+
import { html } from '../html';
|
|
4
5
|
import { assetHash as assetHashFn } from '../utils';
|
|
5
6
|
import { getImportMap, registerImport, resolveImport } from './manifest';
|
|
6
7
|
import type { Hyperspan as HS } from '../types';
|
|
@@ -10,16 +11,87 @@ export { registerImport, resolveImport, getImportMap } from './manifest';
|
|
|
10
11
|
export const JS_PUBLIC_PATH = '/_hs/js';
|
|
11
12
|
export const JS_ISLAND_PUBLIC_PATH = '/_hs/js/islands';
|
|
12
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
|
+
|
|
13
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. */
|
|
14
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). */
|
|
15
35
|
assetHash: string;
|
|
16
36
|
esmName: string;
|
|
17
37
|
publicPath: string;
|
|
18
38
|
exports: string;
|
|
19
39
|
fnArgs: string;
|
|
40
|
+
type: ClientJSType;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type ClientJSPathIdentity = {
|
|
44
|
+
modulePath: string;
|
|
45
|
+
identityKey: string;
|
|
20
46
|
};
|
|
21
47
|
|
|
22
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
|
+
}
|
|
23
95
|
|
|
24
96
|
function getClientJSRegistry(): Map<string, ClientJSEntry> {
|
|
25
97
|
const globalRegistry = globalThis as {
|
|
@@ -60,13 +132,92 @@ export const JS_IMPORT_MAP = {
|
|
|
60
132
|
};
|
|
61
133
|
|
|
62
134
|
/**
|
|
63
|
-
*
|
|
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.
|
|
138
|
+
*/
|
|
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
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return normalized;
|
|
153
|
+
}
|
|
154
|
+
|
|
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;
|
|
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
|
+
|
|
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.
|
|
64
196
|
*/
|
|
65
|
-
|
|
66
|
-
if (
|
|
67
|
-
return
|
|
197
|
+
function resolveClientAbsPath(specifier: string): string {
|
|
198
|
+
if (specifier.startsWith('file://') || isAbsolute(specifier)) {
|
|
199
|
+
return toFilePath(specifier);
|
|
68
200
|
}
|
|
69
|
-
|
|
201
|
+
|
|
202
|
+
const aliased = resolveWithAliases(specifier);
|
|
203
|
+
if (aliased) {
|
|
204
|
+
return aliased;
|
|
205
|
+
}
|
|
206
|
+
|
|
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;
|
|
70
221
|
}
|
|
71
222
|
|
|
72
223
|
export function getClientJSEntries(): ClientJSEntry[] {
|
|
@@ -79,42 +230,91 @@ export function getClientJSEntryByEsmName(esmName: string): ClientJSEntry | unde
|
|
|
79
230
|
|
|
80
231
|
export function resetClientJSEntriesForTests(): void {
|
|
81
232
|
getClientJSRegistry().clear();
|
|
233
|
+
getPathIdentityClientJS().length = 0;
|
|
234
|
+
const aliases = getPathAliases();
|
|
235
|
+
for (const key of Object.keys(aliases)) {
|
|
236
|
+
delete aliases[key];
|
|
237
|
+
}
|
|
82
238
|
}
|
|
83
239
|
|
|
84
|
-
function
|
|
85
|
-
|
|
86
|
-
}
|
|
240
|
+
function registerClientJS(
|
|
241
|
+
modulePathResolved: string,
|
|
242
|
+
options: BuildClientJSOptions = {}
|
|
243
|
+
): HS.ClientJSBuildResult {
|
|
244
|
+
const type = options.type ?? 'module';
|
|
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
|
+
}
|
|
87
254
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
* Never evaluates the module on the server — only registers the path for Vite to bundle
|
|
92
|
-
* and returns URLs / script-tag helpers for the browser.
|
|
93
|
-
*/
|
|
94
|
-
export async function buildClientJS(modulePathResolved: string): Promise<HS.ClientJSBuildResult> {
|
|
95
|
-
const absPath = resolveClientModulePath(modulePathResolved);
|
|
96
|
-
const hash = assetHashFn(absPath);
|
|
255
|
+
const identityKey = clientIdentityKey(specifier);
|
|
256
|
+
const identityFromPath = identityKey !== specifier;
|
|
257
|
+
const hash = assetHashFn(identityKey);
|
|
97
258
|
const esmName = `client-${hash}`;
|
|
98
|
-
const publicPath = resolveImport(esmName) ?? `${JS_PUBLIC_PATH}/${esmName}.js`;
|
|
99
259
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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;
|
|
107
280
|
}
|
|
108
281
|
|
|
109
|
-
|
|
110
|
-
|
|
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);
|
|
111
304
|
|
|
112
305
|
return {
|
|
113
306
|
assetHash: hash,
|
|
114
307
|
esmName,
|
|
115
|
-
publicPath
|
|
308
|
+
get publicPath() {
|
|
309
|
+
return clientPublicPath(esmName);
|
|
310
|
+
},
|
|
116
311
|
renderScriptTag: (loadScript) => {
|
|
312
|
+
if (type === 'iife') {
|
|
313
|
+
return html`<script src="${clientPublicPath(esmName)}"></script>`;
|
|
314
|
+
}
|
|
315
|
+
|
|
117
316
|
const t = typeof loadScript;
|
|
317
|
+
const { exports, fnArgs } = currentExports();
|
|
118
318
|
|
|
119
319
|
if (t === 'string') {
|
|
120
320
|
return html`
|
|
@@ -142,6 +342,32 @@ export async function buildClientJS(modulePathResolved: string): Promise<HS.Clie
|
|
|
142
342
|
};
|
|
143
343
|
}
|
|
144
344
|
|
|
345
|
+
/**
|
|
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')`.
|
|
353
|
+
*/
|
|
354
|
+
export async function buildClientJS(
|
|
355
|
+
modulePathResolved: string,
|
|
356
|
+
options: BuildClientJSOptions = {}
|
|
357
|
+
): Promise<HS.ClientJSBuildResult> {
|
|
358
|
+
return registerClientJS(modulePathResolved, options);
|
|
359
|
+
}
|
|
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.
|
|
363
|
+
export const streamingClient = registerClientJS(
|
|
364
|
+
'@hyperspan/framework/client/_hs/hyperspan-streaming.client.ts',
|
|
365
|
+
{ type: 'iife' }
|
|
366
|
+
);
|
|
367
|
+
export const actionsClient = registerClientJS(
|
|
368
|
+
'@hyperspan/framework/client/_hs/hyperspan-actions.client.ts'
|
|
369
|
+
);
|
|
370
|
+
|
|
145
371
|
/**
|
|
146
372
|
* Discover export names from source or bundled client JS.
|
|
147
373
|
* Prefers bundled `export{...}` form, then common source export patterns.
|
package/src/client/manifest.ts
CHANGED
|
@@ -3,59 +3,64 @@ 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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
+
}
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* Set the build-time asset manifest (called by Vite plugin or build step).
|
|
21
23
|
*/
|
|
22
24
|
export function setAssetManifest(manifest: AssetManifest): void {
|
|
23
|
-
|
|
25
|
+
const store = sharedManifest();
|
|
26
|
+
store.imports = { ...(manifest.imports ?? {}) };
|
|
27
|
+
store.css = { ...(manifest.css ?? {}) };
|
|
28
|
+
store.clients = { ...(manifest.clients ?? {}) };
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
/**
|
|
27
32
|
* Get the current asset manifest.
|
|
28
33
|
*/
|
|
29
34
|
export function getAssetManifest(): AssetManifest {
|
|
30
|
-
return
|
|
35
|
+
return sharedManifest();
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
/**
|
|
34
39
|
* Resolve a module import name to its public URL.
|
|
35
40
|
*/
|
|
36
41
|
export function resolveImport(name: string): string | undefined {
|
|
37
|
-
return
|
|
42
|
+
return sharedManifest().imports[name];
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
/**
|
|
41
46
|
* Get CSS files for a route path.
|
|
42
47
|
*/
|
|
43
48
|
export function getRouteCss(path: string): string[] {
|
|
44
|
-
return
|
|
49
|
+
return sharedManifest().css[path] ?? [];
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
/**
|
|
48
53
|
* Build import map object for layout script tags.
|
|
49
54
|
*/
|
|
50
55
|
export function getImportMap(): Record<string, string> {
|
|
51
|
-
return { ...
|
|
56
|
+
return { ...sharedManifest().imports };
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
/**
|
|
55
60
|
* Register a client module URL at runtime (dev fallback).
|
|
56
61
|
*/
|
|
57
62
|
export function registerImport(name: string, publicPath: string): void {
|
|
58
|
-
|
|
63
|
+
sharedManifest().imports[name] = publicPath;
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
export type ClientJSBuildResult = HS.ClientJSBuildResult;
|
|
@@ -67,7 +72,7 @@ export function getClientJSFromManifest(
|
|
|
67
72
|
moduleId: string,
|
|
68
73
|
_exportNames = 'default'
|
|
69
74
|
): ClientJSBuildResult {
|
|
70
|
-
const publicPath =
|
|
75
|
+
const publicPath = sharedManifest().clients[moduleId];
|
|
71
76
|
if (!publicPath) {
|
|
72
77
|
throw new Error(
|
|
73
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',
|
package/src/fetch-handler.ts
CHANGED
|
@@ -141,10 +141,11 @@ export function createFetchHandler(
|
|
|
141
141
|
|
|
142
142
|
const matched = matchRoute(compiledRoutes, pathname, method);
|
|
143
143
|
if (matched) {
|
|
144
|
-
|
|
145
|
-
|
|
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(
|
|
148
|
+
return matched.route.fetch(request);
|
|
148
149
|
}
|
|
149
150
|
|
|
150
151
|
if (options.onNotMatched) {
|
package/src/html.test.ts
ADDED
|
@@ -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,8 +20,21 @@ 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';
|
|
24
|
+
export type { BuildClientJSOptions, ClientJSType } from './client/js';
|
|
25
|
+
export { registerRouteModule, registerRouteModules } from './register-routes';
|
|
26
|
+
export type { RouteModuleEntry } from './register-routes';
|
|
23
27
|
export type { AssetManifest } from './client/manifest';
|
|
24
28
|
export { renderIsland } from './island';
|
|
25
29
|
export type { IslandRenderOptions } from './island';
|
|
26
|
-
export type {
|
|
30
|
+
export type {
|
|
31
|
+
Hyperspan,
|
|
32
|
+
ServerCreateContext,
|
|
33
|
+
DeployAdapter,
|
|
34
|
+
Adapter,
|
|
35
|
+
AdapterAfterBuildContext,
|
|
36
|
+
DeployEntry,
|
|
37
|
+
DeployEntryContext,
|
|
38
|
+
ServerEntrySlots,
|
|
39
|
+
ServerEntryTemplate,
|
|
40
|
+
} from './types';
|
package/src/island.ts
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { test, expect } from 'vitest';
|
|
2
|
+
import { render } from './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
|
-
import { html } from '
|
|
1
|
+
import { html } from './html';
|
|
2
2
|
import { CSS_PUBLIC_PATH } from './client/css';
|
|
3
|
-
import { getImportMap, getRouteCss
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { getRunnableRoute } from './server';
|
|
2
|
+
import { isValidRoutePath, parsePath } from './utils';
|
|
3
|
+
import type { AssetManifest } from './client/manifest';
|
|
4
|
+
import type { Hyperspan as HS } from './types';
|
|
5
|
+
|
|
6
|
+
export type RouteModuleEntry = {
|
|
7
|
+
file: string;
|
|
8
|
+
mod: unknown;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Register a route or action module on a Hyperspan server.
|
|
13
|
+
* Shared by the CLI, Vite plugin, and the generated production server entry.
|
|
14
|
+
*/
|
|
15
|
+
export function registerRouteModule(
|
|
16
|
+
server: HS.Server,
|
|
17
|
+
relativeFilePath: string,
|
|
18
|
+
mod: unknown,
|
|
19
|
+
manifest?: AssetManifest
|
|
20
|
+
): HS.Route | null {
|
|
21
|
+
if (!isValidRoutePath(relativeFilePath)) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const route = getRunnableRoute(mod);
|
|
26
|
+
const parsedPath = parsePath(relativeFilePath);
|
|
27
|
+
|
|
28
|
+
let path = parsedPath.path;
|
|
29
|
+
if (typeof route._path === 'function') {
|
|
30
|
+
const routePath = route._path();
|
|
31
|
+
if (routePath && routePath !== '/') path = routePath;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!route._config.path) {
|
|
35
|
+
route._config.path = path;
|
|
36
|
+
if (parsedPath.params.length > 0) {
|
|
37
|
+
const params = route._config.params ?? {};
|
|
38
|
+
parsedPath.params.forEach((param) => {
|
|
39
|
+
params[param] = undefined;
|
|
40
|
+
});
|
|
41
|
+
route._config.params = params;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (manifest) {
|
|
46
|
+
const cssFiles = manifest.css?.[path] ?? manifest.css?.['*'];
|
|
47
|
+
if (cssFiles?.length) {
|
|
48
|
+
route._config.cssImports = cssFiles;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
server._routes.push(route);
|
|
53
|
+
return route;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function registerRouteModules(
|
|
57
|
+
server: HS.Server,
|
|
58
|
+
modules: RouteModuleEntry[],
|
|
59
|
+
manifest?: AssetManifest
|
|
60
|
+
): void {
|
|
61
|
+
for (const { file, mod } of modules) {
|
|
62
|
+
registerRouteModule(server, file, mod, manifest);
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/server.test.ts
CHANGED
|
@@ -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 '
|
|
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,17 +1,10 @@
|
|
|
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';
|
|
14
6
|
import { Cookies } from './cookies';
|
|
7
|
+
import { nodeAdapter } from '@hyperspan/adapter-node';
|
|
15
8
|
|
|
16
9
|
import type { Hyperspan as HS } from './types';
|
|
17
10
|
|
|
@@ -51,6 +44,7 @@ export function createConfig(config: Partial<HS.Config> = {}): HS.Config {
|
|
|
51
44
|
appDir: './app',
|
|
52
45
|
publicDir: './public',
|
|
53
46
|
plugins: [],
|
|
47
|
+
deployAdapter: config.deployAdapter ?? nodeAdapter(),
|
|
54
48
|
responseOptions: {
|
|
55
49
|
disableStreaming: hyperspanDisableStreaming,
|
|
56
50
|
},
|
|
@@ -58,6 +52,7 @@ export function createConfig(config: Partial<HS.Config> = {}): HS.Config {
|
|
|
58
52
|
return {
|
|
59
53
|
...defaultConfig,
|
|
60
54
|
...config,
|
|
55
|
+
deployAdapter: config.deployAdapter ?? defaultConfig.deployAdapter,
|
|
61
56
|
responseOptions: {
|
|
62
57
|
...defaultConfig.responseOptions,
|
|
63
58
|
...config.responseOptions,
|
|
@@ -72,7 +67,8 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
72
67
|
const url = new URL(req.url);
|
|
73
68
|
const query = new URLSearchParams(url.search);
|
|
74
69
|
const method = req.method.toUpperCase();
|
|
75
|
-
const
|
|
70
|
+
const requestHeaders = new Headers(req.headers);
|
|
71
|
+
const responseHeaders = new Headers();
|
|
76
72
|
const path = route?._path() || '/';
|
|
77
73
|
const requestParams = (req as Request & { params?: Record<string, string | undefined> }).params;
|
|
78
74
|
const params: Record<string, string | undefined> = Object.assign(
|
|
@@ -92,12 +88,21 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
92
88
|
// Status override for the response. Will use if set. (e.g. c.res.status = 400)
|
|
93
89
|
let status: number | undefined = undefined;
|
|
94
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
|
+
|
|
95
102
|
const merge = async (response: Response) => {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
...Object.fromEntries(response.headers.entries()),
|
|
100
|
-
};
|
|
103
|
+
const mergedHeaders = new Headers();
|
|
104
|
+
copyHeaders(responseHeaders, mergedHeaders);
|
|
105
|
+
copyHeaders(response.headers, mergedHeaders);
|
|
101
106
|
|
|
102
107
|
return new Response(await response.text(), {
|
|
103
108
|
status: context.res.status ?? response.status,
|
|
@@ -121,7 +126,7 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
121
126
|
raw: req,
|
|
122
127
|
url,
|
|
123
128
|
method,
|
|
124
|
-
headers,
|
|
129
|
+
headers: requestHeaders,
|
|
125
130
|
query,
|
|
126
131
|
cookies: new Cookies(req),
|
|
127
132
|
async text() {
|
|
@@ -138,8 +143,8 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
138
143
|
},
|
|
139
144
|
},
|
|
140
145
|
res: {
|
|
141
|
-
cookies: new Cookies(req,
|
|
142
|
-
headers,
|
|
146
|
+
cookies: new Cookies(req, responseHeaders),
|
|
147
|
+
headers: responseHeaders,
|
|
143
148
|
status,
|
|
144
149
|
html: (html: string, options?: ResponseInit) =>
|
|
145
150
|
merge(
|
|
@@ -501,6 +506,20 @@ export async function createServer(config: HS.Config = {} as HS.Config): Promise
|
|
|
501
506
|
return api;
|
|
502
507
|
}
|
|
503
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
|
+
|
|
504
523
|
/**
|
|
505
524
|
* Checks if a response is HTML content
|
|
506
525
|
*/
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { HSHtml } from '
|
|
1
|
+
import { HSHtml } from './html';
|
|
2
2
|
import * as z from 'zod';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -63,13 +63,78 @@ export namespace Hyperspan {
|
|
|
63
63
|
disableStreaming?: DisableStreamingFn;
|
|
64
64
|
};
|
|
65
65
|
|
|
66
|
+
export type AdapterAfterBuildContext = {
|
|
67
|
+
root: string;
|
|
68
|
+
appDir: string;
|
|
69
|
+
outDir: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type ServerCreateContext = {
|
|
73
|
+
env: unknown;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type DeployEntryContext = {
|
|
77
|
+
createHyperspanServer: (ctx: ServerCreateContext) => Promise<Server>;
|
|
78
|
+
config: Config;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type DeployEntry = {
|
|
82
|
+
start?(options?: { port?: number }): unknown | Promise<unknown>;
|
|
83
|
+
fetch?(request: Request, env?: unknown, ctx?: unknown): Response | Promise<Response>;
|
|
84
|
+
};
|
|
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
|
+
|
|
98
|
+
export type Adapter = {
|
|
99
|
+
/** Diagnostic name, e.g. `node`, `cloudflare`. */
|
|
100
|
+
name: string;
|
|
101
|
+
/**
|
|
102
|
+
* Native ESM specifier exporting `resolveDevEnv(root)`.
|
|
103
|
+
* Imported by the Vite plugin so platform SDKs like wrangler load correctly.
|
|
104
|
+
*/
|
|
105
|
+
devModule?: string;
|
|
106
|
+
resolveDevEnv?(root: string): Promise<unknown>;
|
|
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>;
|
|
112
|
+
afterBuild?(ctx: AdapterAfterBuildContext): void | Promise<void>;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** Runtime adapter object (`nodeAdapter()`, `cloudflareAdapter()`, …). */
|
|
116
|
+
export type DeployAdapter = Adapter;
|
|
117
|
+
|
|
66
118
|
export type Config = {
|
|
67
119
|
appDir: string;
|
|
68
120
|
publicDir: string;
|
|
69
|
-
|
|
121
|
+
/** Island plugins (`preactPlugin()`, `sveltePlugin()`, …). */
|
|
122
|
+
plugins: Array<Hyperspan.Plugin>;
|
|
123
|
+
/**
|
|
124
|
+
* Deployment adapter. Pass `cloudflareAdapter()` or `bunAdapter()`.
|
|
125
|
+
* Omit (or pass `nodeAdapter()`) to deploy to Node.
|
|
126
|
+
*/
|
|
127
|
+
deployAdapter: Adapter;
|
|
128
|
+
/**
|
|
129
|
+
* Called before the server is created, with platform bindings.
|
|
130
|
+
* - Vite/`hyperspan dev`: adapter `devModule` `resolveDevEnv(root)` when present; else `process.env`
|
|
131
|
+
* - Node/Bun production entry: `process.env`
|
|
132
|
+
* - Cloudflare Workers entry: Worker `env` bindings
|
|
133
|
+
*/
|
|
134
|
+
beforeServerCreate?: (ctx: ServerCreateContext) => void | Promise<void>;
|
|
70
135
|
// For customizing the routes and adding your own...
|
|
71
|
-
beforeRoutesAdded?: (server: Hyperspan.Server) => void
|
|
72
|
-
afterRoutesAdded?: (server: Hyperspan.Server) => void
|
|
136
|
+
beforeRoutesAdded?: (server: Hyperspan.Server) => void | Promise<void>;
|
|
137
|
+
afterRoutesAdded?: (server: Hyperspan.Server) => void | Promise<void>;
|
|
73
138
|
responseOptions?: ResponseOptions;
|
|
74
139
|
};
|
|
75
140
|
|
|
@@ -365,7 +430,7 @@ export namespace Hyperspan {
|
|
|
365
430
|
* Client JS Module = ESM Module + Public Path + Render Script Tag
|
|
366
431
|
*/
|
|
367
432
|
export type ClientJSBuildResult = {
|
|
368
|
-
assetHash: string; //
|
|
433
|
+
assetHash: string; // Identity of the resolved path; production file hash comes from Vite
|
|
369
434
|
esmName: string; // Filename of the built JavaScript file without the extension
|
|
370
435
|
publicPath: string; // Full public path of the built JavaScript file
|
|
371
436
|
/**
|
|
@@ -387,6 +452,15 @@ export namespace Hyperspan {
|
|
|
387
452
|
}
|
|
388
453
|
}
|
|
389
454
|
|
|
455
|
+
export type DeployAdapter = Hyperspan.DeployAdapter;
|
|
456
|
+
export type ServerCreateContext = Hyperspan.ServerCreateContext;
|
|
457
|
+
export type Adapter = Hyperspan.Adapter;
|
|
458
|
+
export type AdapterAfterBuildContext = Hyperspan.AdapterAfterBuildContext;
|
|
459
|
+
export type DeployEntry = Hyperspan.DeployEntry;
|
|
460
|
+
export type DeployEntryContext = Hyperspan.DeployEntryContext;
|
|
461
|
+
export type ServerEntrySlots = Hyperspan.ServerEntrySlots;
|
|
462
|
+
export type ServerEntryTemplate = Hyperspan.ServerEntryTemplate;
|
|
463
|
+
|
|
390
464
|
declare global {
|
|
391
465
|
interface DocumentEventMap {
|
|
392
466
|
'hs:action:before-fetch': CustomEvent<Hyperspan.ActionFetchDetail>;
|