@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 +22 -7
- package/src/actions.test.ts +1 -1
- package/src/actions.ts +3 -6
- package/src/client/js.test.ts +3 -3
- package/src/client/js.ts +58 -83
- package/src/client/manifest.ts +87 -0
- package/src/fetch-handler.test.ts +90 -0
- package/src/fetch-handler.ts +167 -0
- package/src/index.ts +11 -0
- package/src/layout.ts +34 -9
- package/src/server.test.ts +12 -1
- package/src/server.ts +5 -1
- package/src/ssr/mock-dom.test.ts +1 -1
- package/src/types.ts +12 -0
- package/src/utils.test.ts +72 -2
- package/src/utils.ts +66 -10
- package/tsconfig.json +1 -1
package/package.json
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspan/framework",
|
|
3
|
-
"version": "
|
|
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
|
-
"
|
|
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": "
|
|
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": "^
|
|
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"
|
package/src/actions.test.ts
CHANGED
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 {
|
|
6
|
+
import { hyperspanActionsScriptTag } from './layout';
|
|
7
7
|
import { validateBody, ZodValidationError } from './middleware';
|
|
8
|
-
import
|
|
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
|
-
>${
|
|
138
|
+
>${hyperspanActionsScriptTag()}`
|
|
142
139
|
: null;
|
|
143
140
|
},
|
|
144
141
|
errorHandler(handler) {
|
package/src/client/js.test.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { describe, expect, test } from '
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
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
|
-
*
|
|
11
|
+
* Backward-compatible import map (backed by asset manifest).
|
|
18
12
|
*/
|
|
19
|
-
export
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
|
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:
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
-
|
|
7
|
-
|
|
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": ${
|
|
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 = '${
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/server.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { test, expect, describe } from '
|
|
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,
|
package/src/ssr/mock-dom.test.ts
CHANGED
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 '
|
|
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
|
+
}
|