@hyperspan/framework 1.0.29 → 2.0.0-alpha.13
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 +30 -7
- package/src/actions.test.ts +1 -1
- package/src/actions.ts +3 -6
- package/src/adapter.test.ts +23 -0
- package/src/client/_hs/hyperspan-actions.client.ts +37 -45
- package/src/client/js.test.ts +157 -4
- package/src/client/js.ts +287 -64
- package/src/client/manifest.ts +83 -0
- package/src/dev-bindings.ts +14 -0
- package/src/fetch-handler.test.ts +113 -0
- package/src/fetch-handler.ts +168 -0
- package/src/index.ts +27 -1
- package/src/island.test.ts +30 -0
- package/src/island.ts +37 -0
- package/src/layout.test.ts +12 -0
- package/src/layout.ts +28 -11
- package/src/register-routes.ts +64 -0
- package/src/server.test.ts +60 -2
- package/src/server.ts +42 -11
- package/src/ssr/mock-dom.test.ts +1 -1
- package/src/types.ts +72 -3
- package/src/utils.test.ts +72 -2
- package/src/utils.ts +66 -10
- package/tsconfig.json +4 -8
package/src/types.ts
CHANGED
|
@@ -63,13 +63,63 @@ 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 DeployEntryContext = {
|
|
73
|
+
createHyperspanServer: () => Promise<Server>;
|
|
74
|
+
config: Config;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type DeployEntry = {
|
|
78
|
+
start?(options?: { port?: number }): unknown | Promise<unknown>;
|
|
79
|
+
fetch?(request: Request, env?: unknown, ctx?: unknown): Response | Promise<Response>;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type Adapter = {
|
|
83
|
+
/** Diagnostic name, e.g. `node`, `cloudflare`. */
|
|
84
|
+
name: string;
|
|
85
|
+
/**
|
|
86
|
+
* Native ESM specifier exporting `resolveDevEnv(root)`.
|
|
87
|
+
* Imported by the Vite plugin so platform SDKs like wrangler load correctly.
|
|
88
|
+
*/
|
|
89
|
+
devModule?: string;
|
|
90
|
+
resolveDevEnv?(root: string): Promise<unknown>;
|
|
91
|
+
/** Platform entry (`start` and/or `fetch`). Called from generated `dist/server.ts`. */
|
|
92
|
+
createEntry(ctx: DeployEntryContext): DeployEntry;
|
|
93
|
+
afterBuild?(ctx: AdapterAfterBuildContext): void | Promise<void>;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Runtime adapter object (`nodeAdapter()`, `cloudflareAdapter()`, …). */
|
|
97
|
+
export type DeployAdapter = Adapter;
|
|
98
|
+
|
|
99
|
+
export type ServerCreateContext = {
|
|
100
|
+
env: unknown;
|
|
101
|
+
};
|
|
102
|
+
|
|
66
103
|
export type Config = {
|
|
67
104
|
appDir: string;
|
|
68
105
|
publicDir: string;
|
|
69
|
-
|
|
106
|
+
/** Island plugins (`preactPlugin()`, `sveltePlugin()`, …). */
|
|
107
|
+
plugins: Array<Hyperspan.Plugin>;
|
|
108
|
+
/**
|
|
109
|
+
* Deployment adapter. Pass `cloudflareAdapter()` or `bunAdapter()`.
|
|
110
|
+
* Omit (or pass `nodeAdapter()`) to deploy to Node.
|
|
111
|
+
*/
|
|
112
|
+
deployAdapter: Adapter;
|
|
113
|
+
/**
|
|
114
|
+
* Called before the server is created, with platform bindings.
|
|
115
|
+
* - Vite/`hyperspan dev`: adapter `devModule` `resolveDevEnv(root)` when present; else `process.env`
|
|
116
|
+
* - Node/Bun production entry: `process.env`
|
|
117
|
+
* - Cloudflare Workers entry: Worker `env` bindings
|
|
118
|
+
*/
|
|
119
|
+
beforeServerCreate?: (ctx: ServerCreateContext) => void | Promise<void>;
|
|
70
120
|
// For customizing the routes and adding your own...
|
|
71
|
-
beforeRoutesAdded?: (server: Hyperspan.Server) => void
|
|
72
|
-
afterRoutesAdded?: (server: Hyperspan.Server) => void
|
|
121
|
+
beforeRoutesAdded?: (server: Hyperspan.Server) => void | Promise<void>;
|
|
122
|
+
afterRoutesAdded?: (server: Hyperspan.Server) => void | Promise<void>;
|
|
73
123
|
responseOptions?: ResponseOptions;
|
|
74
124
|
};
|
|
75
125
|
|
|
@@ -119,11 +169,23 @@ export namespace Hyperspan {
|
|
|
119
169
|
merge: (response: Response) => Promise<Response>;
|
|
120
170
|
};
|
|
121
171
|
|
|
172
|
+
export type UrlDiff = {
|
|
173
|
+
pathname?: string | null;
|
|
174
|
+
searchParams?: Record<string, string | number | boolean | null | undefined> | null;
|
|
175
|
+
hash?: string | null;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export type UrlOptions = {
|
|
179
|
+
/** When true, start from pathname only (drop current query string and hash). */
|
|
180
|
+
clean?: boolean;
|
|
181
|
+
};
|
|
182
|
+
|
|
122
183
|
export interface Context {
|
|
123
184
|
vars: Record<string, any>;
|
|
124
185
|
route: RouteConfig;
|
|
125
186
|
req: HSRequest;
|
|
126
187
|
res: HSResponse;
|
|
188
|
+
url: (diff?: UrlDiff, options?: UrlOptions) => string;
|
|
127
189
|
}
|
|
128
190
|
|
|
129
191
|
export type ClientIslandOptions = {
|
|
@@ -375,6 +437,13 @@ export namespace Hyperspan {
|
|
|
375
437
|
}
|
|
376
438
|
}
|
|
377
439
|
|
|
440
|
+
export type DeployAdapter = Hyperspan.DeployAdapter;
|
|
441
|
+
export type ServerCreateContext = Hyperspan.ServerCreateContext;
|
|
442
|
+
export type Adapter = Hyperspan.Adapter;
|
|
443
|
+
export type AdapterAfterBuildContext = Hyperspan.AdapterAfterBuildContext;
|
|
444
|
+
export type DeployEntry = Hyperspan.DeployEntry;
|
|
445
|
+
export type DeployEntryContext = Hyperspan.DeployEntryContext;
|
|
446
|
+
|
|
378
447
|
declare global {
|
|
379
448
|
interface DocumentEventMap {
|
|
380
449
|
'hs:action:before-fetch': CustomEvent<Hyperspan.ActionFetchDetail>;
|
package/src/utils.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { test, expect, describe } from '
|
|
2
|
-
import { formDataToJSON, parsePath } from './utils';
|
|
1
|
+
import { test, expect, describe } from 'vitest';
|
|
2
|
+
import { buildUrl, formDataToJSON, parsePath } from './utils';
|
|
3
3
|
|
|
4
4
|
describe('formDataToJSON', () => {
|
|
5
5
|
test('formDataToJSON returns empty object for empty FormData', () => {
|
|
@@ -228,3 +228,73 @@ describe('parsePath', () => {
|
|
|
228
228
|
});
|
|
229
229
|
});
|
|
230
230
|
|
|
231
|
+
describe('buildUrl', () => {
|
|
232
|
+
test('returns a new URL and does not mutate the base', () => {
|
|
233
|
+
const base = new URL('http://localhost:3000/posts?sort=asc#top');
|
|
234
|
+
const result = buildUrl(base, { searchParams: { page: 2 } });
|
|
235
|
+
|
|
236
|
+
expect(result).toBeInstanceOf(URL);
|
|
237
|
+
expect(result.pathname).toBe('/posts');
|
|
238
|
+
expect(result.search).toBe('?sort=asc&page=2');
|
|
239
|
+
expect(result.hash).toBe('#top');
|
|
240
|
+
expect(base.search).toBe('?sort=asc');
|
|
241
|
+
expect(base.hash).toBe('#top');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('adds and overwrites search params', () => {
|
|
245
|
+
const base = new URL('http://localhost:3000/posts?sort=asc#top');
|
|
246
|
+
const result = buildUrl(base, { searchParams: { page: 2 } });
|
|
247
|
+
|
|
248
|
+
expect(`${result.pathname}${result.search}${result.hash}`).toBe('/posts?sort=asc&page=2#top');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('removes search params with null values', () => {
|
|
252
|
+
const base = new URL('http://localhost:3000/posts?sort=asc&page=1#top');
|
|
253
|
+
const result = buildUrl(base, { searchParams: { sort: null, page: 3 } });
|
|
254
|
+
|
|
255
|
+
expect(`${result.pathname}${result.search}${result.hash}`).toBe('/posts?page=3#top');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('clears all search params when searchParams is null', () => {
|
|
259
|
+
const base = new URL('http://localhost:3000/posts?sort=asc#top');
|
|
260
|
+
const result = buildUrl(base, { searchParams: null });
|
|
261
|
+
|
|
262
|
+
expect(`${result.pathname}${result.search}${result.hash}`).toBe('/posts#top');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('changes pathname and clears hash', () => {
|
|
266
|
+
const base = new URL('http://localhost:3000/posts?sort=asc#top');
|
|
267
|
+
const result = buildUrl(base, { pathname: '/archive', hash: null, searchParams: null });
|
|
268
|
+
|
|
269
|
+
expect(`${result.pathname}${result.search}${result.hash}`).toBe('/archive');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('preserves relative parts when diff is omitted', () => {
|
|
273
|
+
const base = new URL('http://localhost:3000/posts?sort=asc#top');
|
|
274
|
+
const result = buildUrl(base);
|
|
275
|
+
|
|
276
|
+
expect(`${result.pathname}${result.search}${result.hash}`).toBe('/posts?sort=asc#top');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('stringifies boolean and number search values', () => {
|
|
280
|
+
const base = new URL('http://localhost:3000/posts');
|
|
281
|
+
const result = buildUrl(base, { searchParams: { active: true, page: 2 } });
|
|
282
|
+
|
|
283
|
+
expect(result.searchParams.get('active')).toBe('true');
|
|
284
|
+
expect(result.searchParams.get('page')).toBe('2');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('clean: true drops existing query and hash before applying diff', () => {
|
|
288
|
+
const base = new URL('http://localhost:3000/posts?sort=asc#top');
|
|
289
|
+
const result = buildUrl(base, { searchParams: { page: 2 } }, { clean: true });
|
|
290
|
+
|
|
291
|
+
expect(`${result.pathname}${result.search}${result.hash}`).toBe('/posts?page=2');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('pathname null resets to root', () => {
|
|
295
|
+
const base = new URL('http://localhost:3000/posts?sort=asc');
|
|
296
|
+
const result = buildUrl(base, { pathname: null });
|
|
297
|
+
|
|
298
|
+
expect(result.pathname).toBe('/');
|
|
299
|
+
});
|
|
300
|
+
});
|
package/src/utils.ts
CHANGED
|
@@ -1,20 +1,36 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Hyperspan as HS } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FNV-1a 64-bit hash — portable sync hash for asset IDs (works on Node, Bun, Workers).
|
|
5
|
+
*/
|
|
6
|
+
function fnv1a64(input: string): string {
|
|
7
|
+
let hash = BigInt('0xcbf29ce484222325');
|
|
8
|
+
const prime = BigInt('0x100000001b3');
|
|
9
|
+
const mask = BigInt('0xffffffffffffffff');
|
|
10
|
+
for (let i = 0; i < input.length; i++) {
|
|
11
|
+
hash ^= BigInt(input.charCodeAt(i));
|
|
12
|
+
hash = (hash * prime) & mask;
|
|
13
|
+
}
|
|
14
|
+
return hash.toString(16).padStart(16, '0');
|
|
15
|
+
}
|
|
2
16
|
|
|
3
17
|
export function assetHash(content: string): string {
|
|
4
|
-
return
|
|
18
|
+
return fnv1a64(content);
|
|
5
19
|
}
|
|
6
20
|
|
|
7
21
|
export function randomHash(): string {
|
|
8
|
-
|
|
22
|
+
const bytes = new Uint8Array(16);
|
|
23
|
+
crypto.getRandomValues(bytes);
|
|
24
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
25
|
+
return fnv1a64(hex);
|
|
9
26
|
}
|
|
10
27
|
|
|
11
|
-
|
|
12
28
|
/**
|
|
13
29
|
* Normalize URL path
|
|
14
30
|
* Removes trailing slash and lowercases path
|
|
15
31
|
*/
|
|
16
32
|
const ROUTE_SEGMENT_REGEX = /(\[[a-zA-Z_\.]+\])/g;
|
|
17
|
-
export function parsePath(urlPath: string): { path: string
|
|
33
|
+
export function parsePath(urlPath: string): { path: string; params: string[] } {
|
|
18
34
|
const params: string[] = [];
|
|
19
35
|
urlPath = urlPath.replace('index', '').replace('.ts', '').replace('.js', '');
|
|
20
36
|
|
|
@@ -46,15 +62,14 @@ export function parsePath(urlPath: string): { path: string, params: string[] } {
|
|
|
46
62
|
|
|
47
63
|
// Only lowercase non-param segments (do not lowercase after ':')
|
|
48
64
|
return {
|
|
49
|
-
path:
|
|
65
|
+
path:
|
|
50
66
|
'/' +
|
|
51
67
|
urlPath
|
|
52
68
|
.split('/')
|
|
53
69
|
.map((segment) =>
|
|
54
70
|
segment.startsWith(':') || segment === '*' ? segment : segment.toLowerCase()
|
|
55
71
|
)
|
|
56
|
-
.join('/')
|
|
57
|
-
),
|
|
72
|
+
.join('/'),
|
|
58
73
|
params,
|
|
59
74
|
};
|
|
60
75
|
}
|
|
@@ -75,7 +90,9 @@ export function isValidRoutePath(path: string): boolean {
|
|
|
75
90
|
*
|
|
76
91
|
* @link https://stackoverflow.com/a/75406413
|
|
77
92
|
*/
|
|
78
|
-
export function formDataToJSON(
|
|
93
|
+
export function formDataToJSON(
|
|
94
|
+
formData: FormData | URLSearchParams
|
|
95
|
+
): Record<string, string | string[]> {
|
|
79
96
|
let object = {};
|
|
80
97
|
|
|
81
98
|
/**
|
|
@@ -170,4 +187,43 @@ export function formDataToJSON(formData: FormData | URLSearchParams): Record<str
|
|
|
170
187
|
*/
|
|
171
188
|
export function removeUndefined(obj: Record<string, any>): Record<string, any> {
|
|
172
189
|
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
|
|
173
|
-
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Build a new URL from a base URL and an optional diff.
|
|
194
|
+
* Does not mutate the base URL.
|
|
195
|
+
*/
|
|
196
|
+
export function buildUrl(base: URL, diff?: HS.UrlDiff, options?: HS.UrlOptions): URL {
|
|
197
|
+
const next = new URL(base);
|
|
198
|
+
|
|
199
|
+
if (options?.clean) {
|
|
200
|
+
next.search = '';
|
|
201
|
+
next.hash = '';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (diff?.pathname === null) {
|
|
205
|
+
next.pathname = '/';
|
|
206
|
+
} else if (diff?.pathname !== undefined) {
|
|
207
|
+
next.pathname = diff.pathname;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (diff?.searchParams === null) {
|
|
211
|
+
next.search = '';
|
|
212
|
+
} else if (diff?.searchParams !== undefined) {
|
|
213
|
+
for (const [key, value] of Object.entries(diff.searchParams)) {
|
|
214
|
+
if (value === null || value === undefined) {
|
|
215
|
+
next.searchParams.delete(key);
|
|
216
|
+
} else {
|
|
217
|
+
next.searchParams.set(key, String(value));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (diff?.hash === null) {
|
|
223
|
+
next.hash = '';
|
|
224
|
+
} else if (diff?.hash !== undefined) {
|
|
225
|
+
next.hash = diff.hash;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return next;
|
|
229
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"compileOnSave": true,
|
|
3
|
+
"include": ["src/**/*.ts"],
|
|
3
4
|
"compilerOptions": {
|
|
4
5
|
"outDir": "dist",
|
|
5
|
-
"target": "
|
|
6
|
+
"target": "ES2020",
|
|
6
7
|
"lib": ["ESNext", "dom", "dom.iterable"],
|
|
7
8
|
"module": "esnext",
|
|
8
9
|
"moduleResolution": "bundler",
|
|
@@ -10,7 +11,6 @@
|
|
|
10
11
|
"esModuleInterop": true,
|
|
11
12
|
"allowImportingTsExtensions": true,
|
|
12
13
|
"noEmit": true,
|
|
13
|
-
"composite": true,
|
|
14
14
|
"strict": true,
|
|
15
15
|
"declaration": true,
|
|
16
16
|
"sourceMap": true,
|
|
@@ -19,11 +19,7 @@
|
|
|
19
19
|
"allowSyntheticDefaultImports": true,
|
|
20
20
|
"forceConsistentCasingInFileNames": true,
|
|
21
21
|
"allowJs": true,
|
|
22
|
-
"baseUrl": "."
|
|
23
|
-
"paths": {
|
|
24
|
-
"@hyperspan/html": ["../html/src/html.ts"]
|
|
25
|
-
}
|
|
22
|
+
"baseUrl": "."
|
|
26
23
|
},
|
|
27
|
-
"
|
|
28
|
-
"exclude": ["node_modules", "__tests__", "*.test.ts"]
|
|
24
|
+
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
|
29
25
|
}
|