@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/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
- plugins: Array<Hyperspan.Plugin>; // Loaders for client islands
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 'bun:test';
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 { createHash, randomBytes } from "node:crypto";
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 createHash('md5').update(content).digest('hex');
18
+ return fnv1a64(content);
5
19
  }
6
20
 
7
21
  export function randomHash(): string {
8
- return createHash('md5').update(randomBytes(32).toString('hex')).digest('hex');
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, params: 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(formData: FormData | URLSearchParams): Record<string, string | string[]> {
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": "es2019",
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
- "references": [{ "path": "../html" }],
28
- "exclude": ["node_modules", "__tests__", "*.test.ts"]
24
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
29
25
  }