@hyperspan/framework 1.0.0-alpha.4 → 1.0.0-alpha.6
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 +2 -6
- package/src/actions.ts +1 -70
- package/src/index.ts +1 -1
- package/src/server.ts +5 -61
- package/src/types.ts +4 -1
- package/src/utils.ts +130 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspan/framework",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.6",
|
|
4
4
|
"description": "Hyperspan Web Framework",
|
|
5
5
|
"main": "src/server.ts",
|
|
6
6
|
"types": "src/server.ts",
|
|
@@ -21,10 +21,6 @@
|
|
|
21
21
|
"types": "./src/middleware.ts",
|
|
22
22
|
"default": "./src/middleware.ts"
|
|
23
23
|
},
|
|
24
|
-
"./middleware/zod": {
|
|
25
|
-
"types": "./src/middleware/zod.ts",
|
|
26
|
-
"default": "./src/middleware/zod.ts"
|
|
27
|
-
},
|
|
28
24
|
"./utils": {
|
|
29
25
|
"types": "./src/utils.ts",
|
|
30
26
|
"default": "./src/utils.ts"
|
|
@@ -80,7 +76,7 @@
|
|
|
80
76
|
"typescript": "^5.9.3"
|
|
81
77
|
},
|
|
82
78
|
"dependencies": {
|
|
83
|
-
"@hyperspan/html": "0.
|
|
79
|
+
"@hyperspan/html": "^1.0.0-alpha",
|
|
84
80
|
"zod": "^4.1.12"
|
|
85
81
|
}
|
|
86
82
|
}
|
package/src/actions.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { html, HSHtml } from '@hyperspan/html';
|
|
|
2
2
|
import { createRoute, returnHTMLResponse } from './server';
|
|
3
3
|
import * as z from 'zod/v4';
|
|
4
4
|
import type { Hyperspan as HS } from './types';
|
|
5
|
-
import { assetHash } from './utils';
|
|
5
|
+
import { assetHash, formDataToJSON } from './utils';
|
|
6
6
|
import * as actionsClient from './client/_hs/hyperspan-actions.client';
|
|
7
7
|
import { renderClientJS } from './client/js';
|
|
8
8
|
|
|
@@ -115,73 +115,4 @@ export function createAction<T extends z.ZodTypeAny>(params: { name: string; sch
|
|
|
115
115
|
};
|
|
116
116
|
|
|
117
117
|
return api;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Return JSON data structure for a given FormData object
|
|
122
|
-
* Accounts for array fields (e.g. name="options[]" or <select multiple>)
|
|
123
|
-
*
|
|
124
|
-
* @link https://stackoverflow.com/a/75406413
|
|
125
|
-
*/
|
|
126
|
-
export function formDataToJSON(formData: FormData): Record<string, string | string[]> {
|
|
127
|
-
let object = {};
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Parses FormData key xxx`[x][x][x]` fields into array
|
|
131
|
-
*/
|
|
132
|
-
const parseKey = (key: string) => {
|
|
133
|
-
const subKeyIdx = key.indexOf('[');
|
|
134
|
-
|
|
135
|
-
if (subKeyIdx !== -1) {
|
|
136
|
-
const keys = [key.substring(0, subKeyIdx)];
|
|
137
|
-
key = key.substring(subKeyIdx);
|
|
138
|
-
|
|
139
|
-
for (const match of key.matchAll(/\[(?<key>.*?)]/gm)) {
|
|
140
|
-
if (match.groups) {
|
|
141
|
-
keys.push(match.groups.key);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
return keys;
|
|
145
|
-
} else {
|
|
146
|
-
return [key];
|
|
147
|
-
}
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Recursively iterates over keys and assigns key/values to object
|
|
152
|
-
*/
|
|
153
|
-
const assign = (keys: string[], value: FormDataEntryValue, object: any): void => {
|
|
154
|
-
const key = keys.shift();
|
|
155
|
-
|
|
156
|
-
// When last key in the iterations
|
|
157
|
-
if (key === '' || key === undefined) {
|
|
158
|
-
return object.push(value);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (Reflect.has(object, key)) {
|
|
162
|
-
// If key has been found, but final pass - convert the value to array
|
|
163
|
-
if (keys.length === 0) {
|
|
164
|
-
if (!Array.isArray(object[key])) {
|
|
165
|
-
object[key] = [object[key], value];
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
// Recurse again with found object
|
|
170
|
-
return assign(keys, value, object[key]);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Create empty object for key, if next key is '' do array instead, otherwise set value
|
|
174
|
-
if (keys.length >= 1) {
|
|
175
|
-
object[key] = keys[0] === '' ? [] : {};
|
|
176
|
-
return assign(keys, value, object[key]);
|
|
177
|
-
} else {
|
|
178
|
-
object[key] = value;
|
|
179
|
-
}
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
for (const pair of formData.entries()) {
|
|
183
|
-
assign(parseKey(pair[0]), pair[1], object);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
return object;
|
|
187
118
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { createConfig, createContext, createRoute, createServer, getRunnableRoute, StreamResponse } from './server';
|
|
1
|
+
export { createConfig, createContext, createRoute, createServer, getRunnableRoute, StreamResponse, IS_PROD } from './server';
|
|
2
2
|
export type { Hyperspan } from './types';
|
package/src/server.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { HSHtml, html, isHSHtml, renderStream, renderAsync, render } from '@hype
|
|
|
2
2
|
import { executeMiddleware } from './middleware';
|
|
3
3
|
import type { Hyperspan as HS } from './types';
|
|
4
4
|
import { clientJSPlugin } from './plugins';
|
|
5
|
+
import { parsePath } from './utils';
|
|
5
6
|
export type { HS as Hyperspan };
|
|
6
7
|
|
|
7
8
|
export const IS_PROD = process.env.NODE_ENV === 'production';
|
|
@@ -49,7 +50,10 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
49
50
|
method,
|
|
50
51
|
headers,
|
|
51
52
|
query,
|
|
52
|
-
|
|
53
|
+
async text() { return req.text() },
|
|
54
|
+
async json<T = unknown>() { return await req.json() as T },
|
|
55
|
+
async formData<T = unknown>() { return await req.formData() as T },
|
|
56
|
+
async urlencoded() { return new URLSearchParams(await req.text()) },
|
|
53
57
|
},
|
|
54
58
|
res: {
|
|
55
59
|
headers: new Headers(),
|
|
@@ -365,16 +369,6 @@ export function isRunnableRoute(route: unknown): boolean {
|
|
|
365
369
|
return typeof obj?._kind === 'string' && 'fetch' in obj;
|
|
366
370
|
}
|
|
367
371
|
|
|
368
|
-
/**
|
|
369
|
-
* Is valid route path to add to server?
|
|
370
|
-
*/
|
|
371
|
-
export function isValidRoutePath(path: string): boolean {
|
|
372
|
-
const isHiddenRoute = path.includes('/__');
|
|
373
|
-
const isTestFile = path.includes('.test') || path.includes('.spec');
|
|
374
|
-
|
|
375
|
-
return !isHiddenRoute && !isTestFile && Boolean(path);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
372
|
/**
|
|
379
373
|
* Basic error handling
|
|
380
374
|
* @TODO: Should check for and load user-customizeable template with special name (app/__error.ts ?)
|
|
@@ -470,53 +464,3 @@ export function createReadableStreamFromAsyncGenerator(output: AsyncGenerator) {
|
|
|
470
464
|
},
|
|
471
465
|
});
|
|
472
466
|
}
|
|
473
|
-
|
|
474
|
-
/**
|
|
475
|
-
* Normalize URL path
|
|
476
|
-
* Removes trailing slash and lowercases path
|
|
477
|
-
*/
|
|
478
|
-
const ROUTE_SEGMENT_REGEX = /(\[[a-zA-Z_\.]+\])/g;
|
|
479
|
-
export function parsePath(urlPath: string): { path: string, params: string[] } {
|
|
480
|
-
const params: string[] = [];
|
|
481
|
-
urlPath = urlPath.replace('index', '').replace('.ts', '').replace('.js', '');
|
|
482
|
-
|
|
483
|
-
if (urlPath.startsWith('/')) {
|
|
484
|
-
urlPath = urlPath.substring(1);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
if (urlPath.endsWith('/')) {
|
|
488
|
-
urlPath = urlPath.substring(0, urlPath.length - 1);
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
if (!urlPath) {
|
|
492
|
-
return { path: '/', params: [] };
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// Dynamic params
|
|
496
|
-
if (ROUTE_SEGMENT_REGEX.test(urlPath)) {
|
|
497
|
-
urlPath = urlPath.replace(ROUTE_SEGMENT_REGEX, (match: string) => {
|
|
498
|
-
const paramName = match.replace(/[^a-zA-Z_\.]+/g, '');
|
|
499
|
-
params.push(paramName);
|
|
500
|
-
|
|
501
|
-
if (match.includes('...')) {
|
|
502
|
-
return '*';
|
|
503
|
-
} else {
|
|
504
|
-
return ':' + paramName;
|
|
505
|
-
}
|
|
506
|
-
});
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
// Only lowercase non-param segments (do not lowercase after ':')
|
|
510
|
-
return {
|
|
511
|
-
path: (
|
|
512
|
-
'/' +
|
|
513
|
-
urlPath
|
|
514
|
-
.split('/')
|
|
515
|
-
.map((segment) =>
|
|
516
|
-
segment.startsWith(':') || segment === '*' ? segment : segment.toLowerCase()
|
|
517
|
-
)
|
|
518
|
-
.join('/')
|
|
519
|
-
),
|
|
520
|
-
params,
|
|
521
|
-
};
|
|
522
|
-
}
|
package/src/types.ts
CHANGED
|
@@ -42,7 +42,10 @@ export namespace Hyperspan {
|
|
|
42
42
|
method: string; // Always uppercase
|
|
43
43
|
headers: Headers; // Case-insensitive
|
|
44
44
|
query: URLSearchParams;
|
|
45
|
-
|
|
45
|
+
text: () => Promise<string>;
|
|
46
|
+
json<T = unknown>(): Promise<T>;
|
|
47
|
+
formData<T = unknown>(): Promise<T>;
|
|
48
|
+
urlencoded(): Promise<URLSearchParams>;
|
|
46
49
|
};
|
|
47
50
|
res: {
|
|
48
51
|
headers: Headers; // Headers to merge with final outgoing response
|
package/src/utils.ts
CHANGED
|
@@ -6,4 +6,134 @@ export function assetHash(content: string): string {
|
|
|
6
6
|
|
|
7
7
|
export function randomHash(): string {
|
|
8
8
|
return createHash('md5').update(randomBytes(32).toString('hex')).digest('hex');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Normalize URL path
|
|
14
|
+
* Removes trailing slash and lowercases path
|
|
15
|
+
*/
|
|
16
|
+
const ROUTE_SEGMENT_REGEX = /(\[[a-zA-Z_\.]+\])/g;
|
|
17
|
+
export function parsePath(urlPath: string): { path: string, params: string[] } {
|
|
18
|
+
const params: string[] = [];
|
|
19
|
+
urlPath = urlPath.replace('index', '').replace('.ts', '').replace('.js', '');
|
|
20
|
+
|
|
21
|
+
if (urlPath.startsWith('/')) {
|
|
22
|
+
urlPath = urlPath.substring(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (urlPath.endsWith('/')) {
|
|
26
|
+
urlPath = urlPath.substring(0, urlPath.length - 1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!urlPath) {
|
|
30
|
+
return { path: '/', params: [] };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Dynamic params
|
|
34
|
+
if (ROUTE_SEGMENT_REGEX.test(urlPath)) {
|
|
35
|
+
urlPath = urlPath.replace(ROUTE_SEGMENT_REGEX, (match: string) => {
|
|
36
|
+
const paramName = match.replace(/[^a-zA-Z_\.]+/g, '');
|
|
37
|
+
params.push(paramName);
|
|
38
|
+
|
|
39
|
+
if (match.includes('...')) {
|
|
40
|
+
return '*';
|
|
41
|
+
} else {
|
|
42
|
+
return ':' + paramName;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Only lowercase non-param segments (do not lowercase after ':')
|
|
48
|
+
return {
|
|
49
|
+
path: (
|
|
50
|
+
'/' +
|
|
51
|
+
urlPath
|
|
52
|
+
.split('/')
|
|
53
|
+
.map((segment) =>
|
|
54
|
+
segment.startsWith(':') || segment === '*' ? segment : segment.toLowerCase()
|
|
55
|
+
)
|
|
56
|
+
.join('/')
|
|
57
|
+
),
|
|
58
|
+
params,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Is valid route path to add to server?
|
|
64
|
+
*/
|
|
65
|
+
export function isValidRoutePath(path: string): boolean {
|
|
66
|
+
const isHiddenRoute = path.includes('/__');
|
|
67
|
+
const isTestFile = path.includes('.test') || path.includes('.spec');
|
|
68
|
+
|
|
69
|
+
return !isHiddenRoute && !isTestFile && Boolean(path);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Return JSON data structure for a given FormData or URLSearchParams object
|
|
74
|
+
* Accounts for array fields (e.g. name="options[]" or <select multiple>)
|
|
75
|
+
*
|
|
76
|
+
* @link https://stackoverflow.com/a/75406413
|
|
77
|
+
*/
|
|
78
|
+
export function formDataToJSON(formData: FormData | URLSearchParams): Record<string, string | string[]> {
|
|
79
|
+
let object = {};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parses FormData key xxx`[x][x][x]` fields into array
|
|
83
|
+
*/
|
|
84
|
+
const parseKey = (key: string) => {
|
|
85
|
+
const subKeyIdx = key.indexOf('[');
|
|
86
|
+
|
|
87
|
+
if (subKeyIdx !== -1) {
|
|
88
|
+
const keys = [key.substring(0, subKeyIdx)];
|
|
89
|
+
key = key.substring(subKeyIdx);
|
|
90
|
+
|
|
91
|
+
for (const match of key.matchAll(/\[(?<key>.*?)]/gm)) {
|
|
92
|
+
if (match.groups) {
|
|
93
|
+
keys.push(match.groups.key);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return keys;
|
|
97
|
+
} else {
|
|
98
|
+
return [key];
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Recursively iterates over keys and assigns key/values to object
|
|
104
|
+
*/
|
|
105
|
+
const assign = (keys: string[], value: FormDataEntryValue, object: any): void => {
|
|
106
|
+
const key = keys.shift();
|
|
107
|
+
|
|
108
|
+
// When last key in the iterations
|
|
109
|
+
if (key === '' || key === undefined) {
|
|
110
|
+
return object.push(value);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (Reflect.has(object, key)) {
|
|
114
|
+
// If key has been found, but final pass - convert the value to array
|
|
115
|
+
if (keys.length === 0) {
|
|
116
|
+
if (!Array.isArray(object[key])) {
|
|
117
|
+
object[key] = [object[key], value];
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Recurse again with found object
|
|
122
|
+
return assign(keys, value, object[key]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Create empty object for key, if next key is '' do array instead, otherwise set value
|
|
126
|
+
if (keys.length >= 1) {
|
|
127
|
+
object[key] = keys[0] === '' ? [] : {};
|
|
128
|
+
return assign(keys, value, object[key]);
|
|
129
|
+
} else {
|
|
130
|
+
object[key] = value;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
for (const pair of formData.entries()) {
|
|
135
|
+
assign(parseKey(pair[0]), pair[1], object);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return object;
|
|
9
139
|
}
|