@hyperspan/framework 1.0.29 → 2.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "name": "@hyperspan/framework",
3
- "version": "1.0.29",
3
+ "version": "2.0.0-alpha.1",
4
4
  "description": "Hyperspan Web Framework",
5
+ "type": "module",
5
6
  "main": "src/server.ts",
6
7
  "types": "src/server.ts",
7
8
  "public": true,
8
9
  "publishConfig": {
9
- "access": "public"
10
+ "access": "public",
11
+ "tag": "alpha"
10
12
  },
11
13
  "exports": {
12
14
  ".": {
@@ -37,6 +39,10 @@
37
39
  "types": "./src/client/js.ts",
38
40
  "default": "./src/client/js.ts"
39
41
  },
42
+ "./client/manifest": {
43
+ "types": "./src/client/manifest.ts",
44
+ "default": "./src/client/manifest.ts"
45
+ },
40
46
  "./actions": {
41
47
  "types": "./src/actions.ts",
42
48
  "default": "./src/actions.ts"
@@ -48,6 +54,15 @@
48
54
  "./ssr/mock-dom": {
49
55
  "types": "./src/ssr/mock-dom.ts",
50
56
  "default": "./src/ssr/mock-dom.ts"
57
+ },
58
+ "./client/_hs/hyperspan-streaming.client.ts": {
59
+ "default": "./src/client/_hs/hyperspan-streaming.client.ts"
60
+ },
61
+ "./client/_hs/hyperspan-actions.client.ts": {
62
+ "default": "./src/client/_hs/hyperspan-actions.client.ts"
63
+ },
64
+ "./client/_hs/hyperspan-scripts.client.ts": {
65
+ "default": "./src/client/_hs/hyperspan-scripts.client.ts"
51
66
  }
52
67
  },
53
68
  "author": "Vance Lucas <vance@vancelucas.com>",
@@ -55,7 +70,7 @@
55
70
  "keywords": [
56
71
  "framework",
57
72
  "node",
58
- "bun",
73
+ "vite",
59
74
  "web framework",
60
75
  "javascript",
61
76
  "typescript",
@@ -71,17 +86,17 @@
71
86
  "url": "https://github.com/vlucas/hyperspan/issues"
72
87
  },
73
88
  "scripts": {
74
- "test": "bun test"
89
+ "test": "vitest run"
75
90
  },
76
91
  "devDependencies": {
77
- "@types/bun": "^1.3.1",
78
92
  "@types/debug": "^4.1.12",
79
93
  "@types/node": "^24.10.0",
80
94
  "prettier": "^3.5.2",
81
- "typescript": "^5.9.3"
95
+ "typescript": "^5.9.3",
96
+ "vitest": "^3.2.4"
82
97
  },
83
98
  "dependencies": {
84
- "@hyperspan/html": "^1.0.1",
99
+ "@hyperspan/html": "^2.0.0-alpha.1",
85
100
  "debug": "^4.4.3",
86
101
  "isbot": "^5.1.32",
87
102
  "zod": "^4.4.3"
@@ -1,4 +1,4 @@
1
- import { test, expect, describe } from 'bun:test';
1
+ import { test, expect, describe } from 'vitest';
2
2
  import { createAction } from './actions';
3
3
  import { createRoute } from './server';
4
4
  import { html, render, placeholder, type HSHtml } from '@hyperspan/html';
package/src/actions.ts CHANGED
@@ -3,14 +3,11 @@ import { createRoute, HTTPResponseException, returnHTMLResponse } from './server
3
3
  import * as z from 'zod';
4
4
  import type { Hyperspan as HS } from './types';
5
5
  import { assetHash, formDataToJSON } from './utils';
6
- import { buildClientJS } from './client/js';
6
+ import { hyperspanActionsScriptTag } from './layout';
7
7
  import { validateBody, ZodValidationError } from './middleware';
8
- import { debug } from 'debug';
8
+ import debug from 'debug';
9
9
 
10
10
  const log = debug('hyperspan:actions');
11
- const actionsClientJS = await buildClientJS(
12
- import.meta.resolve('./client/_hs/hyperspan-actions.client')
13
- );
14
11
 
15
12
  /**
16
13
  * Actions = Form + route handler
@@ -138,7 +135,7 @@ export function createAction<S extends z.ZodType>(params: {
138
135
  const formContent = api._form ? api._form(c, props || {}) : null;
139
136
  return formContent
140
137
  ? html`<hs-action url="${this._path()}">${formContent}</hs-action
141
- >${actionsClientJS.renderScriptTag()}`
138
+ >${hyperspanActionsScriptTag()}`
142
139
  : null;
143
140
  },
144
141
  errorHandler(handler) {
@@ -1,9 +1,10 @@
1
- import { describe, expect, test } from 'bun:test';
1
+ import { describe, expect, test } from 'vitest';
2
2
  import { extractExports } from './js';
3
3
 
4
4
  describe('extractExports', () => {
5
5
  test('extracts aliased export from bundled JS content', () => {
6
- const contents = 'function d(n,t){if(t)n.classList.remove("hidden");else n.classList.add("hidden")}function p(){let n=document.querySelector("[data-google-spreadsheet-id]"),t=document.querySelector("[data-google-sheet-select]"),a=document.querySelector("[data-google-sheet-message]");if(console.log("googleSheetsPickerClient",n,t),!n||!t)return;let l=(e)=>{if(!a)return;a.textContent=e||"",d(a,Boolean(e))},h=(e)=>{t.innerHTML="",e.forEach((s)=>{let o=document.createElement("option");o.value=s,o.textContent=s,t.appendChild(o)})},r=(e)=>{d(t,e),t.disabled=!e,t.required=e},c=async()=>{let e=n.value.trim();if(l(null),!e){r(!1);return}try{let s=await fetch(`/api/google-sheets/${encodeURIComponent(e)}`),o=await s.json();if(!s.ok||o.error)throw Error(o.error||"Unable to load sheet names.");let i=(o.sheets||[]).map((u)=>u.title).filter(Boolean);if(i.length===0){l("No sheets found in that spreadsheet."),r(!1);return}h(i),t.value=i[0],r(!0)}catch(s){l(s instanceof Error?s.message:"Unable to load sheet names."),r(!1)}};n.addEventListener("input",c),n.addEventListener("change",c)}export{p as mountGoogleSheetsPicker};';
6
+ const contents =
7
+ 'function d(n,t){if(t)n.classList.remove("hidden");else n.classList.add("hidden")}function p(){let n=document.querySelector("[data-google-spreadsheet-id]"),t=document.querySelector("[data-google-sheet-select]"),a=document.querySelector("[data-google-sheet-message]");if(console.log("googleSheetsPickerClient",n,t),!n||!t)return;let l=(e)=>{if(!a)return;a.textContent=e||"",d(a,Boolean(e))},h=(e)=>{t.innerHTML="",e.forEach((s)=>{let o=document.createElement("option");o.value=s,o.textContent=s,t.appendChild(o)})},r=(e)=>{d(t,e),t.disabled=!e,t.required=e},c=async()=>{let e=n.value.trim();if(l(null),!e){r(!1);return}try{let s=await fetch(`/api/google-sheets/${encodeURIComponent(e)}`),o=await s.json();if(!s.ok||o.error)throw Error(o.error||"Unable to load sheet names.");let i=(o.sheets||[]).map((u)=>u.title).filter(Boolean);if(i.length===0){l("No sheets found in that spreadsheet."),r(!1);return}h(i),t.value=i[0],r(!0)}catch(s){l(s instanceof Error?s.message:"Unable to load sheet names."),r(!1)}};n.addEventListener("input",c),n.addEventListener("change",c)}export{p as mountGoogleSheetsPicker};';
7
8
 
8
9
  const result = extractExports(contents);
9
10
 
@@ -24,4 +25,3 @@ describe('extractExports', () => {
24
25
  });
25
26
  });
26
27
  });
27
-
package/src/client/js.ts CHANGED
@@ -1,97 +1,73 @@
1
1
  import { html } from '@hyperspan/html';
2
- import { assetHash as assetHashFn } from '../utils';
3
- import { join } from 'node:path';
2
+ import { getImportMap, registerImport, resolveImport } from './manifest';
4
3
  import type { Hyperspan as HS } from '../types';
5
4
 
6
- const CWD = process.cwd();
7
- const IS_PROD = process.env.NODE_ENV === 'production';
5
+ export { registerImport, resolveImport, getImportMap } from './manifest';
8
6
 
9
7
  export const JS_PUBLIC_PATH = '/_hs/js';
10
8
  export const JS_ISLAND_PUBLIC_PATH = '/_hs/js/islands';
11
- export const JS_IMPORT_MAP = new Map<string, string>();
12
- const CLIENT_JS_CACHE = new Map<string, { esmName: string, exports: string, fnArgs: string, publicPath: string }>();
13
- const CLIENT_JS_BUILD_PROMISES = new Map<string, Promise<void>>();
14
- const EXPORT_REGEX = /export\{(.*)\}/g;
15
9
 
16
10
  /**
17
- * Build a client JS module and return a Hyperspan.ClientJSBuildResult object
11
+ * Backward-compatible import map (backed by asset manifest).
18
12
  */
19
- export async function buildClientJS(modulePathResolved: string): Promise<HS.ClientJSBuildResult> {
20
- const modulePath = modulePathResolved.replace('file://', '');
21
- const assetHash = assetHashFn(modulePath);
22
-
23
- // Cache: Avoid re-processing the same file
24
- if (!CLIENT_JS_CACHE.has(assetHash)) {
25
- const existingBuild = CLIENT_JS_BUILD_PROMISES.get(assetHash);
26
- // Await the existing build promise if it exists (this can get called in parallel from Bun traversing imports)
27
- if (existingBuild) {
28
- await existingBuild;
29
- } else {
30
- const buildPromise = (async () => {
31
- // Build the client JS module
32
- const result = await Bun.build({
33
- entrypoints: [modulePath],
34
- outdir: join(CWD, './public', JS_PUBLIC_PATH), // @TODO: Make this configurable... should be read from config file...
35
- naming: IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined,
36
- external: Array.from(JS_IMPORT_MAP.keys()),
37
- minify: true,
38
- format: 'esm',
39
- target: 'browser',
40
- env: 'APP_PUBLIC_*',
41
- });
42
-
43
- // Add output file to import map
44
- const esmName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', '');
45
- const publicPath = `${JS_PUBLIC_PATH}/${esmName}.js`;
46
- JS_IMPORT_MAP.set(esmName, publicPath);
47
-
48
- // Get the contents of the file to extract the exports
49
- const contents = await result.outputs[0].text();
50
- const { exports, fnArgs } = extractExports(contents);
51
-
52
- CLIENT_JS_CACHE.set(assetHash, { esmName, exports, fnArgs, publicPath });
53
- })();
54
-
55
- CLIENT_JS_BUILD_PROMISES.set(assetHash, buildPromise);
56
- try {
57
- await buildPromise;
58
- } finally {
59
- CLIENT_JS_BUILD_PROMISES.delete(assetHash);
60
- }
61
- }
62
- }
13
+ export const JS_IMPORT_MAP = {
14
+ get size() {
15
+ return Object.keys(getImportMap()).length;
16
+ },
17
+ keys() {
18
+ return Object.keys(getImportMap()).values();
19
+ },
20
+ has(key: string) {
21
+ return resolveImport(key) !== undefined;
22
+ },
23
+ get(key: string) {
24
+ return resolveImport(key);
25
+ },
26
+ set(key: string, value: string) {
27
+ registerImport(key, value);
28
+ return this;
29
+ },
30
+ entries() {
31
+ return Object.entries(getImportMap()).values();
32
+ },
33
+ [Symbol.iterator]() {
34
+ return Object.entries(getImportMap())[Symbol.iterator]();
35
+ },
36
+ };
63
37
 
64
- const { esmName, exports, fnArgs, publicPath } = CLIENT_JS_CACHE.get(assetHash)!;
65
-
66
- return {
67
- assetHash,
68
- esmName,
69
- publicPath,
70
- renderScriptTag: (loadScript) => {
71
- const t = typeof loadScript;
72
-
73
- if (t === 'string') {
74
- return html`
75
- <script type="module" data-source-id="${assetHash}">import ${exports} from "${esmName}";\n(${html.raw(loadScript as string)})(${fnArgs});</script>
76
- `;
77
- }
78
- if (t === 'function') {
79
- return html`
80
- <script type="module" data-source-id="${assetHash}">import ${exports} from "${esmName}";\n(${html.raw(functionToString(loadScript))})(${fnArgs});</script>
81
- `;
82
- }
38
+ const EXPORT_REGEX = /export\{(.*)\}/g;
83
39
 
84
- return html`
85
- <script type="module" data-source-id="${assetHash}">import "${esmName}";</script>
86
- `;
87
- }
40
+ /**
41
+ * @deprecated Use build-time asset manifest via @hyperspan/vite-plugin instead.
42
+ * Kept for compatibility — returns manifest entry if available.
43
+ */
44
+ export async function buildClientJS(_modulePathResolved: string): Promise<HS.ClientJSBuildResult> {
45
+ const { getClientJSFromManifest } = await import('./manifest');
46
+ const moduleId = _modulePathResolved.includes('streaming')
47
+ ? 'streaming'
48
+ : _modulePathResolved.includes('actions')
49
+ ? 'actions'
50
+ : 'scripts';
51
+
52
+ try {
53
+ return getClientJSFromManifest(moduleId);
54
+ } catch {
55
+ const esmName = moduleId;
56
+ const publicPath = `${JS_PUBLIC_PATH}/hyperspan-${moduleId}.client.js`;
57
+ registerImport(esmName, publicPath);
58
+ return {
59
+ assetHash: moduleId,
60
+ esmName,
61
+ publicPath,
62
+ renderScriptTag: () => html`<script type="module" src="${publicPath}"></script>`,
63
+ };
88
64
  }
89
65
  }
90
66
 
91
67
  /**
92
68
  * Extract the exports from a client JS module
93
69
  */
94
- export function extractExports(contents: string): { exports: string, fnArgs: string } {
70
+ export function extractExports(contents: string): { exports: string; fnArgs: string } {
95
71
  const exportLine = EXPORT_REGEX.exec(contents);
96
72
  let exports = '{}';
97
73
  let fnArgs = '{}';
@@ -111,8 +87,8 @@ export function extractExports(contents: string): { exports: string, fnArgs: str
111
87
  fnArgs = exports.replace(/(\w+)\s*as\s*(\w+)/g, '$1: $2').trim();
112
88
 
113
89
  if (exports === '{}' && fnArgs === '{}') {
114
- exports = '* as _module'
115
- fnArgs = '_module'
90
+ exports = '* as _module';
91
+ fnArgs = '_module';
116
92
  }
117
93
 
118
94
  return { exports, fnArgs };
@@ -120,8 +96,7 @@ export function extractExports(contents: string): { exports: string, fnArgs: str
120
96
 
121
97
  /**
122
98
  * Convert a function to a string (results in loss of context!)
123
- * Handles named, async, and arrow functions
124
99
  */
125
- export function functionToString(fn: any) {
126
- return fn.toString().trim();
127
- }
100
+ export function functionToString(fn: unknown) {
101
+ return (fn as (...args: unknown[]) => unknown).toString().trim();
102
+ }
@@ -0,0 +1,87 @@
1
+ import type { Hyperspan as HS } from '../types';
2
+
3
+ export type AssetManifest = {
4
+ imports: Record<string, string>;
5
+ css: Record<string, string[]>;
6
+ clients: {
7
+ streaming?: string;
8
+ actions?: string;
9
+ scripts?: string;
10
+ };
11
+ };
12
+
13
+ let _manifest: AssetManifest = {
14
+ imports: {},
15
+ css: {},
16
+ clients: {},
17
+ };
18
+
19
+ /**
20
+ * Set the build-time asset manifest (called by Vite plugin or build step).
21
+ */
22
+ export function setAssetManifest(manifest: AssetManifest): void {
23
+ _manifest = manifest;
24
+ }
25
+
26
+ /**
27
+ * Get the current asset manifest.
28
+ */
29
+ export function getAssetManifest(): AssetManifest {
30
+ return _manifest;
31
+ }
32
+
33
+ /**
34
+ * Resolve a module import name to its public URL.
35
+ */
36
+ export function resolveImport(name: string): string | undefined {
37
+ return _manifest.imports[name];
38
+ }
39
+
40
+ /**
41
+ * Get CSS files for a route path.
42
+ */
43
+ export function getRouteCss(path: string): string[] {
44
+ return _manifest.css[path] ?? [];
45
+ }
46
+
47
+ /**
48
+ * Build import map object for layout script tags.
49
+ */
50
+ export function getImportMap(): Record<string, string> {
51
+ return { ..._manifest.imports };
52
+ }
53
+
54
+ /**
55
+ * Register a client module URL at runtime (dev fallback).
56
+ */
57
+ export function registerImport(name: string, publicPath: string): void {
58
+ _manifest.imports[name] = publicPath;
59
+ }
60
+
61
+ export type ClientJSBuildResult = HS.ClientJSBuildResult;
62
+
63
+ /**
64
+ * Create a ClientJSBuildResult from manifest data (replaces runtime Bun.build).
65
+ */
66
+ export function getClientJSFromManifest(
67
+ moduleId: string,
68
+ _exportNames = 'default'
69
+ ): ClientJSBuildResult {
70
+ const publicPath = _manifest.clients[moduleId as keyof AssetManifest['clients']];
71
+ if (!publicPath) {
72
+ throw new Error(
73
+ `[Hyperspan] Client module "${moduleId}" not found in asset manifest. Run hyperspan build or start dev server.`
74
+ );
75
+ }
76
+
77
+ const esmName = publicPath.split('/').pop()?.replace('.js', '') ?? moduleId;
78
+
79
+ return {
80
+ assetHash: moduleId,
81
+ esmName,
82
+ publicPath,
83
+ renderScriptTag: () => {
84
+ throw new Error('renderScriptTag requires html template - use layout helpers instead');
85
+ },
86
+ };
87
+ }
@@ -0,0 +1,90 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { createRoute, createServer } from './server';
3
+ import { createFetchHandler, compileRoutePath } from './fetch-handler';
4
+ import type { Hyperspan as HS } from './types';
5
+
6
+ describe('compileRoutePath', () => {
7
+ test('matches static paths', () => {
8
+ const { pattern } = compileRoutePath('/about');
9
+ expect('/about'.match(pattern)).toBeTruthy();
10
+ expect('/about/'.match(pattern)).toBeTruthy();
11
+ });
12
+
13
+ test('matches dynamic params', () => {
14
+ const { pattern, paramNames } = compileRoutePath('/users/:id');
15
+ expect(paramNames).toEqual(['id']);
16
+ const match = '/users/42'.match(pattern);
17
+ expect(match?.[1]).toBe('42');
18
+ });
19
+
20
+ test('matches wildcard routes', () => {
21
+ const { pattern, paramNames } = compileRoutePath('/blog/*');
22
+ expect(paramNames).toEqual(['...slug']);
23
+ const match = '/blog/hello/world'.match(pattern);
24
+ expect(match?.[1]).toBe('hello/world');
25
+ });
26
+ });
27
+
28
+ describe('createFetchHandler', () => {
29
+ test('routes GET requests to matching handler', async () => {
30
+ const server = await createServer({
31
+ appDir: './app',
32
+ publicDir: './public',
33
+ plugins: [],
34
+ });
35
+
36
+ server.get('/hello', (c: HS.Context) => c.res.html('<h1>Hello</h1>'));
37
+
38
+ const fetch = createFetchHandler(server);
39
+ const response = await fetch(new Request('http://localhost/hello'));
40
+
41
+ expect(response.status).toBe(200);
42
+ expect(await response.text()).toBe('<h1>Hello</h1>');
43
+ });
44
+
45
+ test('returns 404 for unknown paths', async () => {
46
+ const server = await createServer({
47
+ appDir: './app',
48
+ publicDir: './public',
49
+ plugins: [],
50
+ });
51
+
52
+ const fetch = createFetchHandler(server);
53
+ const response = await fetch(new Request('http://localhost/missing'));
54
+
55
+ expect(response.status).toBe(404);
56
+ });
57
+
58
+ test('passes route params to context', async () => {
59
+ const server = await createServer({
60
+ appDir: './app',
61
+ publicDir: './public',
62
+ plugins: [],
63
+ });
64
+
65
+ const route = createRoute({ path: '/items/:id' }).get((c: HS.Context) =>
66
+ c.res.text(c.route.params.id ?? '')
67
+ );
68
+ server._routes.push(route);
69
+
70
+ const fetch = createFetchHandler(server);
71
+ const response = await fetch(new Request('http://localhost/items/abc'));
72
+
73
+ expect(await response.text()).toBe('abc');
74
+ });
75
+
76
+ test('redirects trailing slashes', async () => {
77
+ const server = await createServer({
78
+ appDir: './app',
79
+ publicDir: './public',
80
+ plugins: [],
81
+ });
82
+
83
+ server.get('/page', (c: HS.Context) => c.res.text('ok'));
84
+
85
+ const fetch = createFetchHandler(server);
86
+ const response = await fetch(new Request('http://localhost/page/', { redirect: 'manual' }));
87
+
88
+ expect(response.status).toBe(308);
89
+ });
90
+ });
@@ -0,0 +1,167 @@
1
+ import { createContext } from './server';
2
+ import type { Hyperspan as HS } from './types';
3
+
4
+ export type FetchHandlerOptions = {
5
+ /** Called when no route matches (before 404). Return a Response to handle static assets etc. */
6
+ onNotMatched?: (request: Request) => Promise<Response | undefined>;
7
+ };
8
+
9
+ type CompiledRoute = {
10
+ route: HS.Route;
11
+ pattern: RegExp;
12
+ paramNames: string[];
13
+ isWildcard: boolean;
14
+ };
15
+
16
+ /**
17
+ * Convert a Hyperspan route path (`/users/:id`, `/blog/*`) into a RegExp + param names.
18
+ */
19
+ export function compileRoutePath(path: string): {
20
+ pattern: RegExp;
21
+ paramNames: string[];
22
+ isWildcard: boolean;
23
+ } {
24
+ const paramNames: string[] = [];
25
+ const isWildcard = path.endsWith('/*');
26
+
27
+ if (path === '/') {
28
+ return { pattern: /^\/$/, paramNames, isWildcard: false };
29
+ }
30
+
31
+ const segments = path.split('/').filter(Boolean);
32
+ const regexParts = segments.map((segment) => {
33
+ if (segment === '*') {
34
+ paramNames.push('...slug');
35
+ return '(.+)';
36
+ }
37
+ if (segment.startsWith(':')) {
38
+ paramNames.push(segment.slice(1));
39
+ return '([^/]+)';
40
+ }
41
+ return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
42
+ });
43
+
44
+ const pattern = new RegExp(`^/${regexParts.join('/')}${isWildcard ? '' : '/?'}$`);
45
+ return { pattern, paramNames, isWildcard };
46
+ }
47
+
48
+ function mergeRouteMiddleware(server: HS.Server, route: HS.Route): void {
49
+ route._serverConfig = server._config;
50
+
51
+ if (!server._middleware || !route._middleware) {
52
+ return;
53
+ }
54
+
55
+ for (const method of Object.keys(server._middleware) as HS.MiddlewareMethod[]) {
56
+ if (Array.isArray(server._middleware[method]) && Array.isArray(route._middleware[method])) {
57
+ route._middleware[method] = server._middleware[method].concat(route._middleware[method]);
58
+ }
59
+ }
60
+ }
61
+
62
+ function matchRoute(
63
+ compiledRoutes: CompiledRoute[],
64
+ pathname: string,
65
+ method: string
66
+ ): { route: HS.Route; params: Record<string, string | undefined> } | null {
67
+ for (const compiled of compiledRoutes) {
68
+ const match = pathname.match(compiled.pattern);
69
+ if (!match) {
70
+ continue;
71
+ }
72
+
73
+ // Actions and some custom handlers may not expose _methods(); treat as all methods.
74
+ const routeMethods =
75
+ typeof compiled.route._methods === 'function' ? compiled.route._methods() : ['*'];
76
+ const supportsMethod =
77
+ routeMethods.includes(method) ||
78
+ routeMethods.includes('*') ||
79
+ (method === 'HEAD' && routeMethods.includes('GET'));
80
+
81
+ if (!supportsMethod) {
82
+ continue;
83
+ }
84
+
85
+ const params: Record<string, string | undefined> = {};
86
+ compiled.paramNames.forEach((name, index) => {
87
+ const value = match[index + 1];
88
+ if (name.startsWith('...')) {
89
+ params[name] = value;
90
+ } else {
91
+ params[name] = value;
92
+ }
93
+ });
94
+
95
+ return { route: compiled.route, params };
96
+ }
97
+
98
+ return null;
99
+ }
100
+
101
+ function trailingSlashRedirect(pathname: string, request: Request): Response | null {
102
+ if (pathname.length > 1 && pathname.endsWith('/')) {
103
+ const url = new URL(request.url);
104
+ url.pathname = pathname.slice(0, -1);
105
+ return Response.redirect(url.toString(), 308);
106
+ }
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Create a portable fetch handler for a Hyperspan server.
112
+ * Works on Node, Bun, Cloudflare Workers, and any runtime with Web Fetch API.
113
+ */
114
+ export function createFetchHandler(
115
+ server: HS.Server,
116
+ options: FetchHandlerOptions = {}
117
+ ): (request: Request) => Promise<Response> {
118
+ const compiledRoutes: CompiledRoute[] = server._routes.map((route) => {
119
+ const path = route._path();
120
+ mergeRouteMiddleware(server, route);
121
+ const { pattern, paramNames, isWildcard } = compileRoutePath(path);
122
+ return { route, pattern, paramNames, isWildcard };
123
+ });
124
+
125
+ // Longer / more specific paths first (more segments wins)
126
+ compiledRoutes.sort((a, b) => {
127
+ const aSegments = a.route._path().split('/').filter(Boolean).length;
128
+ const bSegments = b.route._path().split('/').filter(Boolean).length;
129
+ return bSegments - aSegments;
130
+ });
131
+
132
+ return async (request: Request): Promise<Response> => {
133
+ const url = new URL(request.url);
134
+ const pathname = url.pathname;
135
+ const method = request.method.toUpperCase();
136
+
137
+ const slashRedirect = trailingSlashRedirect(pathname, request);
138
+ if (slashRedirect) {
139
+ return slashRedirect;
140
+ }
141
+
142
+ const matched = matchRoute(compiledRoutes, pathname, method);
143
+ if (matched) {
144
+ const reqWithParams = new Request(request);
145
+ (reqWithParams as Request & { params?: Record<string, string | undefined> }).params =
146
+ matched.params;
147
+ return matched.route.fetch(reqWithParams);
148
+ }
149
+
150
+ if (options.onNotMatched) {
151
+ const staticResponse = await options.onNotMatched(request);
152
+ if (staticResponse) {
153
+ return staticResponse;
154
+ }
155
+ }
156
+
157
+ return createContext(request).res.notFound();
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Create an app object with a fetch method (adapter contract).
163
+ */
164
+ export function createApp(server: HS.Server, options: FetchHandlerOptions = {}) {
165
+ const fetch = createFetchHandler(server, options);
166
+ return { fetch, server };
167
+ }
package/src/index.ts CHANGED
@@ -9,4 +9,15 @@ export {
9
9
  HTTPResponseException,
10
10
  hyperspanDisableStreaming,
11
11
  } from './server';
12
+ export { createFetchHandler, createApp, compileRoutePath } from './fetch-handler';
13
+ export type { FetchHandlerOptions } from './fetch-handler';
14
+ export {
15
+ setAssetManifest,
16
+ getAssetManifest,
17
+ resolveImport,
18
+ getRouteCss,
19
+ getImportMap,
20
+ registerImport,
21
+ } from './client/manifest';
22
+ export type { AssetManifest } from './client/manifest';
12
23
  export type { Hyperspan } from './types';
package/src/layout.ts CHANGED
@@ -1,19 +1,27 @@
1
1
  import { html } from '@hyperspan/html';
2
- import { JS_IMPORT_MAP, buildClientJS } from './client/js';
3
- import { CSS_PUBLIC_PATH, CSS_ROUTE_MAP } from './client/css';
2
+ import { CSS_PUBLIC_PATH } from './client/css';
3
+ import { getImportMap, getRouteCss, getClientJSFromManifest } from './client/manifest';
4
4
  import type { Hyperspan as HS } from './types';
5
5
 
6
- const clientStreamingJS = await buildClientJS(
7
- import.meta.resolve('./client/_hs/hyperspan-streaming.client')
8
- );
6
+ function streamingClientPath(): string {
7
+ return getClientJSFromManifest('streaming').publicPath;
8
+ }
9
9
 
10
10
  /**
11
11
  * Output the importmap for the client so we can use ESModules on the client to load JS files on demand
12
12
  */
13
13
  export function hyperspanScriptTags() {
14
+ const imports = getImportMap();
15
+ let streamingPath: string;
16
+ try {
17
+ streamingPath = streamingClientPath();
18
+ } catch {
19
+ streamingPath = '/_hs/js/hyperspan-streaming.client.js';
20
+ }
21
+
14
22
  return html`
15
23
  <script type="importmap">
16
- {"imports": ${Object.fromEntries(JS_IMPORT_MAP)}}
24
+ {"imports": ${imports}}
17
25
  </script>
18
26
  <script id="hyperspan-streaming-script">
19
27
  // [Hyperspan] Streaming - Load the client streaming JS module only when the first chunk is loaded
@@ -25,7 +33,7 @@ export function hyperspanScriptTags() {
25
33
  if (!window._hscLoading) {
26
34
  window._hscLoading = true;
27
35
  const script = document.createElement('script');
28
- script.src = '${clientStreamingJS.publicPath}';
36
+ script.src = '${streamingPath}';
29
37
  document.body.appendChild(script);
30
38
  }
31
39
  };
@@ -39,11 +47,28 @@ export function hyperspanScriptTags() {
39
47
  */
40
48
  export function hyperspanStyleTags(context: HS.Context) {
41
49
  const styleTags = [];
42
- const cssImports = context.route.cssImports ?? CSS_ROUTE_MAP.get(context.route.path) ?? [];
50
+ const cssImports = context.route.cssImports?.length
51
+ ? context.route.cssImports
52
+ : getRouteCss(context.route.path);
43
53
 
44
54
  for (const cssFile of cssImports) {
45
- styleTags.push(html` <link rel="stylesheet" href="${CSS_PUBLIC_PATH}/${cssFile}" /> `);
55
+ // Absolute Vite/dev URLs (e.g. /app/styles/globals.css) are used as-is;
56
+ // hashed build artifacts are served under /_hs/css/.
57
+ const href = cssFile.startsWith('/') ? cssFile : `${CSS_PUBLIC_PATH}/${cssFile}`;
58
+ styleTags.push(html` <link rel="stylesheet" href="${href}" /> `);
46
59
  }
47
60
 
48
61
  return styleTags;
49
62
  }
63
+
64
+ /**
65
+ * Render the actions client script tag (used by createAction).
66
+ */
67
+ export function hyperspanActionsScriptTag() {
68
+ try {
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
+ }
74
+ }
@@ -1,4 +1,4 @@
1
- import { test, expect, describe } from 'bun:test';
1
+ import { test, expect, describe } from 'vitest';
2
2
  import { createRoute, createServer, createContext } from './server';
3
3
  import { createAction } from './actions';
4
4
  import { html, placeholder } from '@hyperspan/html';
@@ -199,6 +199,17 @@ test('createContext() can get and set cookies', () => {
199
199
  }
200
200
  });
201
201
 
202
+ test('createContext() url() returns a relative URL string', () => {
203
+ const request = new Request('http://localhost:3000/posts?sort=asc#top');
204
+ const context = createContext(request);
205
+
206
+ expect(typeof context.url()).toBe('string');
207
+ expect(context.url()).toBe('/posts?sort=asc#top');
208
+ expect(context.url({ searchParams: { page: 2 } })).toBe('/posts?sort=asc&page=2#top');
209
+ expect(context.url({ searchParams: { page: 2 } }, { clean: true })).toBe('/posts?page=2');
210
+ expect(context.req.url.search).toBe('?sort=asc');
211
+ });
212
+
202
213
  test('createContext() merge() function preserves custom headers when using response methods', async () => {
203
214
  // Create a request
204
215
  const request = new Request('http://localhost:3000/');
package/src/server.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  } from '@hyperspan/html';
11
11
  import { isbot } from 'isbot';
12
12
  import { executeMiddleware } from './middleware';
13
- import { parsePath, removeUndefined } from './utils';
13
+ import { buildUrl, parsePath, removeUndefined } from './utils';
14
14
  import { Cookies } from './cookies';
15
15
 
16
16
  import type { Hyperspan as HS } from './types';
@@ -107,6 +107,10 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
107
107
 
108
108
  const context: HS.Context = {
109
109
  vars: {},
110
+ url: (diff, options) => {
111
+ const next = buildUrl(url, diff, options);
112
+ return `${next.pathname}${next.search}${next.hash}`;
113
+ },
110
114
  route: {
111
115
  name: route?._config.name || undefined,
112
116
  path,
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from 'bun:test';
1
+ import { describe, expect, test } from 'vitest';
2
2
  import { MOCK_DOM_MARK, installMockDom, stubElement } from './mock-dom';
3
3
 
4
4
  describe('stubElement', () => {
package/src/types.ts CHANGED
@@ -119,11 +119,23 @@ export namespace Hyperspan {
119
119
  merge: (response: Response) => Promise<Response>;
120
120
  };
121
121
 
122
+ export type UrlDiff = {
123
+ pathname?: string | null;
124
+ searchParams?: Record<string, string | number | boolean | null | undefined> | null;
125
+ hash?: string | null;
126
+ };
127
+
128
+ export type UrlOptions = {
129
+ /** When true, start from pathname only (drop current query string and hash). */
130
+ clean?: boolean;
131
+ };
132
+
122
133
  export interface Context {
123
134
  vars: Record<string, any>;
124
135
  route: RouteConfig;
125
136
  req: HSRequest;
126
137
  res: HSResponse;
138
+ url: (diff?: UrlDiff, options?: UrlOptions) => string;
127
139
  }
128
140
 
129
141
  export type ClientIslandOptions = {
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
@@ -2,7 +2,7 @@
2
2
  "compileOnSave": true,
3
3
  "compilerOptions": {
4
4
  "outDir": "dist",
5
- "target": "es2019",
5
+ "target": "ES2020",
6
6
  "lib": ["ESNext", "dom", "dom.iterable"],
7
7
  "module": "esnext",
8
8
  "moduleResolution": "bundler",