@hyperspan/framework 1.0.0-alpha.1 → 1.0.0-alpha.11
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 +8 -8
- package/src/actions.test.ts +147 -0
- package/src/actions.ts +118 -0
- package/src/client/_hs/hyperspan-actions.client.ts +98 -0
- package/src/client/_hs/hyperspan-scripts.client.ts +31 -0
- package/src/client/_hs/hyperspan-streaming.client.ts +94 -0
- package/src/client/js.ts +0 -21
- package/src/cookies.ts +234 -0
- package/src/index.ts +2 -0
- package/src/plugins.ts +2 -4
- package/src/server.test.ts +141 -17
- package/src/server.ts +58 -86
- package/src/types.ts +73 -19
- package/src/utils.test.ts +196 -0
- package/src/utils.ts +135 -1
- package/tsconfig.json +1 -1
- package/src/clientjs/hyperspan-client.ts +0 -224
- /package/src/{clientjs → client/_hs}/idiomorph.ts +0 -0
package/src/server.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { HSHtml, html, isHSHtml, renderStream, renderAsync, render } from '@hyperspan/html';
|
|
2
2
|
import { executeMiddleware } from './middleware';
|
|
3
|
-
import type { Hyperspan as HS } from './types';
|
|
4
3
|
import { clientJSPlugin } from './plugins';
|
|
5
|
-
|
|
4
|
+
import { parsePath } from './utils';
|
|
5
|
+
import { Cookies } from './cookies';
|
|
6
|
+
|
|
7
|
+
import type { Hyperspan as HS } from './types';
|
|
8
|
+
import { RequestOptions } from 'node:http';
|
|
6
9
|
|
|
7
10
|
export const IS_PROD = process.env.NODE_ENV === 'production';
|
|
8
|
-
const CWD = process.cwd();
|
|
9
11
|
|
|
10
|
-
export class
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
export class HTTPResponseException extends Error {
|
|
13
|
+
public _response?: Response;
|
|
14
|
+
constructor(body: string | undefined, options?: ResponseInit) {
|
|
15
|
+
super(body);
|
|
16
|
+
this._response = new Response(body, options);
|
|
13
17
|
}
|
|
14
18
|
}
|
|
15
19
|
|
|
@@ -37,10 +41,25 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
37
41
|
// @ts-ignore - Bun will put 'params' on the Request object even though it's not standardized
|
|
38
42
|
const params: HS.RouteParamsParser<path> = req?.params || {};
|
|
39
43
|
|
|
44
|
+
const merge = (response: Response) => {
|
|
45
|
+
// Convert headers to plain objects and merge (response headers override context headers)
|
|
46
|
+
const mergedHeaders = {
|
|
47
|
+
...Object.fromEntries(headers.entries()),
|
|
48
|
+
...Object.fromEntries(response.headers.entries()),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return new Response(response.body, {
|
|
52
|
+
status: response.status,
|
|
53
|
+
headers: mergedHeaders,
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
40
57
|
return {
|
|
58
|
+
vars: {},
|
|
41
59
|
route: {
|
|
42
60
|
path,
|
|
43
61
|
params: params,
|
|
62
|
+
cssImports: route ? route._config.cssImports ?? [] : [],
|
|
44
63
|
},
|
|
45
64
|
req: {
|
|
46
65
|
raw: req,
|
|
@@ -48,16 +67,23 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
48
67
|
method,
|
|
49
68
|
headers,
|
|
50
69
|
query,
|
|
51
|
-
|
|
70
|
+
cookies: new Cookies(req),
|
|
71
|
+
async text() { return req.text() },
|
|
72
|
+
async json<T = unknown>() { return await req.json() as T },
|
|
73
|
+
async formData<T = unknown>() { return await req.formData() as T },
|
|
74
|
+
async urlencoded() { return new URLSearchParams(await req.text()) },
|
|
52
75
|
},
|
|
53
76
|
res: {
|
|
77
|
+
cookies: new Cookies(req, headers),
|
|
78
|
+
headers,
|
|
54
79
|
raw: new Response(),
|
|
55
|
-
html: (html: string, options?:
|
|
56
|
-
json: (json: any, options?:
|
|
57
|
-
text: (text: string, options?:
|
|
58
|
-
redirect: (url: string, options?:
|
|
59
|
-
error: (error: Error, options?:
|
|
60
|
-
notFound: (options?:
|
|
80
|
+
html: (html: string, options?: ResponseInit) => merge(new Response(html, { ...options, headers: { 'Content-Type': 'text/html; charset=UTF-8', ...options?.headers } })),
|
|
81
|
+
json: (json: any, options?: ResponseInit) => merge(new Response(JSON.stringify(json), { ...options, headers: { 'Content-Type': 'application/json', ...options?.headers } })),
|
|
82
|
+
text: (text: string, options?: ResponseInit) => merge(new Response(text, { ...options, headers: { 'Content-Type': 'text/plain; charset=UTF-8', ...options?.headers } })),
|
|
83
|
+
redirect: (url: string, options?: ResponseInit) => merge(new Response(null, { status: 302, headers: { Location: url, ...options?.headers } })),
|
|
84
|
+
error: (error: Error, options?: ResponseInit) => merge(new Response(error.message, { status: 500, ...options })),
|
|
85
|
+
notFound: (options?: ResponseInit) => merge(new Response('Not Found', { status: 404, ...options })),
|
|
86
|
+
merge,
|
|
61
87
|
},
|
|
62
88
|
};
|
|
63
89
|
}
|
|
@@ -73,7 +99,6 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
|
73
99
|
|
|
74
100
|
const api: HS.Route = {
|
|
75
101
|
_kind: 'hsRoute',
|
|
76
|
-
_name: config.name,
|
|
77
102
|
_config: config,
|
|
78
103
|
_methods: () => Object.keys(_handlers),
|
|
79
104
|
_path() {
|
|
@@ -132,6 +157,10 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
|
132
157
|
_middleware['OPTIONS'] = handlerOptions?.middleware || [];
|
|
133
158
|
return api;
|
|
134
159
|
},
|
|
160
|
+
errorHandler(handler: HS.RouteHandler) {
|
|
161
|
+
_handlers['_ERROR'] = handler;
|
|
162
|
+
return api;
|
|
163
|
+
},
|
|
135
164
|
/**
|
|
136
165
|
* Add middleware specific to this route
|
|
137
166
|
*/
|
|
@@ -145,12 +174,11 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
|
145
174
|
*/
|
|
146
175
|
async fetch(request: Request) {
|
|
147
176
|
const context = createContext(request, api);
|
|
177
|
+
const method = context.req.method;
|
|
148
178
|
const globalMiddleware = _middleware['*'] || [];
|
|
149
|
-
const methodMiddleware = _middleware[
|
|
179
|
+
const methodMiddleware = _middleware[method] || [];
|
|
150
180
|
|
|
151
181
|
const methodHandler = async (context: HS.Context) => {
|
|
152
|
-
const method = context.req.method;
|
|
153
|
-
|
|
154
182
|
// Handle CORS preflight requests (if no OPTIONS handler is defined)
|
|
155
183
|
if (method === 'OPTIONS' && !_handlers['OPTIONS']) {
|
|
156
184
|
return context.res.html(
|
|
@@ -194,7 +222,16 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
|
194
222
|
return routeContent;
|
|
195
223
|
};
|
|
196
224
|
|
|
197
|
-
|
|
225
|
+
// Run the route handler and any middleware
|
|
226
|
+
// If an error occurs, run the error handler if it exists
|
|
227
|
+
try {
|
|
228
|
+
return await executeMiddleware(context, [...globalMiddleware, ...methodMiddleware, methodHandler]);
|
|
229
|
+
} catch (e) {
|
|
230
|
+
if (_handlers['_ERROR']) {
|
|
231
|
+
return await (_handlers['_ERROR'](context) as Promise<Response>);
|
|
232
|
+
}
|
|
233
|
+
throw e;
|
|
234
|
+
}
|
|
198
235
|
},
|
|
199
236
|
};
|
|
200
237
|
|
|
@@ -327,11 +364,6 @@ export function getRunnableRoute(route: unknown, routeConfig?: HS.RouteConfig):
|
|
|
327
364
|
|
|
328
365
|
const kind = typeof route;
|
|
329
366
|
|
|
330
|
-
// Plain function - wrap in createRoute()
|
|
331
|
-
if (kind === 'function') {
|
|
332
|
-
return createRoute(routeConfig).get(route as HS.RouteHandler);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
367
|
// Module - get default and use it
|
|
336
368
|
// @ts-ignore
|
|
337
369
|
if (kind === 'object' && 'default' in route) {
|
|
@@ -353,17 +385,7 @@ export function isRunnableRoute(route: unknown): boolean {
|
|
|
353
385
|
}
|
|
354
386
|
|
|
355
387
|
const obj = route as { _kind: string; fetch: (request: Request) => Promise<Response> };
|
|
356
|
-
return
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* Is valid route path to add to server?
|
|
361
|
-
*/
|
|
362
|
-
export function isValidRoutePath(path: string): boolean {
|
|
363
|
-
const isHiddenRoute = path.includes('/__');
|
|
364
|
-
const isTestFile = path.includes('.test') || path.includes('.spec');
|
|
365
|
-
|
|
366
|
-
return !isHiddenRoute && !isTestFile;
|
|
388
|
+
return typeof obj?._kind === 'string' && 'fetch' in obj;
|
|
367
389
|
}
|
|
368
390
|
|
|
369
391
|
/**
|
|
@@ -379,8 +401,8 @@ async function showErrorReponse(
|
|
|
379
401
|
const message = err.message || 'Internal Server Error';
|
|
380
402
|
|
|
381
403
|
// Send correct status code if HTTPException
|
|
382
|
-
if (err instanceof
|
|
383
|
-
status = err.status;
|
|
404
|
+
if (err instanceof HTTPResponseException) {
|
|
405
|
+
status = err._response?.status ?? 500;
|
|
384
406
|
}
|
|
385
407
|
|
|
386
408
|
const stack = !IS_PROD && err.stack ? err.stack.split('\n').slice(1).join('\n') : '';
|
|
@@ -461,53 +483,3 @@ export function createReadableStreamFromAsyncGenerator(output: AsyncGenerator) {
|
|
|
461
483
|
},
|
|
462
484
|
});
|
|
463
485
|
}
|
|
464
|
-
|
|
465
|
-
/**
|
|
466
|
-
* Normalize URL path
|
|
467
|
-
* Removes trailing slash and lowercases path
|
|
468
|
-
*/
|
|
469
|
-
const ROUTE_SEGMENT_REGEX = /(\[[a-zA-Z_\.]+\])/g;
|
|
470
|
-
export function parsePath(urlPath: string): { path: string, params: string[] } {
|
|
471
|
-
const params: string[] = [];
|
|
472
|
-
urlPath = urlPath.replace('index', '').replace('.ts', '').replace('.js', '');
|
|
473
|
-
|
|
474
|
-
if (urlPath.startsWith('/')) {
|
|
475
|
-
urlPath = urlPath.substring(1);
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
if (urlPath.endsWith('/')) {
|
|
479
|
-
urlPath = urlPath.substring(0, urlPath.length - 1);
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
if (!urlPath) {
|
|
483
|
-
return { path: '/', params: [] };
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// Dynamic params
|
|
487
|
-
if (ROUTE_SEGMENT_REGEX.test(urlPath)) {
|
|
488
|
-
urlPath = urlPath.replace(ROUTE_SEGMENT_REGEX, (match: string) => {
|
|
489
|
-
const paramName = match.replace(/[^a-zA-Z_\.]+/g, '');
|
|
490
|
-
params.push(paramName);
|
|
491
|
-
|
|
492
|
-
if (match.includes('...')) {
|
|
493
|
-
return '*';
|
|
494
|
-
} else {
|
|
495
|
-
return ':' + paramName;
|
|
496
|
-
}
|
|
497
|
-
});
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
// Only lowercase non-param segments (do not lowercase after ':')
|
|
501
|
-
return {
|
|
502
|
-
path: (
|
|
503
|
-
'/' +
|
|
504
|
-
urlPath
|
|
505
|
-
.split('/')
|
|
506
|
-
.map((segment) =>
|
|
507
|
-
segment.startsWith(':') || segment === '*' ? segment : segment.toLowerCase()
|
|
508
|
-
)
|
|
509
|
-
.join('/')
|
|
510
|
-
),
|
|
511
|
-
params,
|
|
512
|
-
};
|
|
513
|
-
}
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { HSHtml } from '@hyperspan/html';
|
|
2
|
+
import * as z from 'zod/v4';
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* Hyperspan Types
|
|
3
6
|
*/
|
|
@@ -26,6 +29,52 @@ export namespace Hyperspan {
|
|
|
26
29
|
afterRoutesAdded?: (server: Hyperspan.Server) => void;
|
|
27
30
|
};
|
|
28
31
|
|
|
32
|
+
export type CookieOptions = {
|
|
33
|
+
maxAge?: number;
|
|
34
|
+
domain?: string;
|
|
35
|
+
path?: string;
|
|
36
|
+
expires?: Date;
|
|
37
|
+
httpOnly?: boolean;
|
|
38
|
+
secure?: boolean;
|
|
39
|
+
sameSite?: 'lax' | 'strict' | true;
|
|
40
|
+
};
|
|
41
|
+
export type Cookies = {
|
|
42
|
+
_req: Request;
|
|
43
|
+
_responseHeaders: Headers | undefined;
|
|
44
|
+
_parsedCookies: Record<string, any>;
|
|
45
|
+
_encrypt: ((str: string) => string) | undefined;
|
|
46
|
+
_decrypt: ((str: string) => string) | undefined;
|
|
47
|
+
get: (name: string) => string | undefined;
|
|
48
|
+
set: (name: string, value: string, options?: CookieOptions) => void;
|
|
49
|
+
delete: (name: string) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type HSRequest = {
|
|
53
|
+
url: URL;
|
|
54
|
+
raw: Request;
|
|
55
|
+
method: string; // Always uppercase
|
|
56
|
+
headers: Headers; // Case-insensitive
|
|
57
|
+
query: URLSearchParams;
|
|
58
|
+
cookies: Hyperspan.Cookies;
|
|
59
|
+
text: () => Promise<string>;
|
|
60
|
+
json<T = unknown>(): Promise<T>;
|
|
61
|
+
formData(): Promise<FormData>;
|
|
62
|
+
urlencoded(): Promise<URLSearchParams>;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type HSResponse = {
|
|
66
|
+
cookies: Hyperspan.Cookies;
|
|
67
|
+
headers: Headers; // Headers to merge with final outgoing response
|
|
68
|
+
html: (html: string, options?: ResponseInit) => Response
|
|
69
|
+
json: (json: any, options?: ResponseInit) => Response;
|
|
70
|
+
text: (text: string, options?: ResponseInit) => Response;
|
|
71
|
+
redirect: (url: string, options?: ResponseInit) => Response;
|
|
72
|
+
error: (error: Error, options?: ResponseInit) => Response;
|
|
73
|
+
notFound: (options?: ResponseInit) => Response;
|
|
74
|
+
merge: (response: Response) => Response;
|
|
75
|
+
raw: Response;
|
|
76
|
+
};
|
|
77
|
+
|
|
29
78
|
export interface Context {
|
|
30
79
|
vars: Record<string, any>;
|
|
31
80
|
route: {
|
|
@@ -33,24 +82,8 @@ export namespace Hyperspan {
|
|
|
33
82
|
params: Record<string, string>;
|
|
34
83
|
cssImports?: string[];
|
|
35
84
|
}
|
|
36
|
-
req:
|
|
37
|
-
|
|
38
|
-
raw: Request;
|
|
39
|
-
method: string; // Always uppercase
|
|
40
|
-
headers: Headers; // Case-insensitive
|
|
41
|
-
query: URLSearchParams;
|
|
42
|
-
body: any;
|
|
43
|
-
};
|
|
44
|
-
res: {
|
|
45
|
-
headers: Headers; // Headers to merge with final outgoing response
|
|
46
|
-
html: (html: string, options?: { status?: number; headers?: Record<string, string> }) => Response
|
|
47
|
-
json: (json: any, options?: { status?: number; headers?: Record<string, string> }) => Response;
|
|
48
|
-
text: (text: string, options?: { status?: number; headers?: Record<string, string> }) => Response;
|
|
49
|
-
redirect: (url: string, options?: { status?: number; headers?: Record<string, string> }) => Response;
|
|
50
|
-
error: (error: Error, options?: { status?: number; headers?: Record<string, string> }) => Response;
|
|
51
|
-
notFound: (options?: { status?: number; headers?: Record<string, string> }) => Response;
|
|
52
|
-
raw: Response;
|
|
53
|
-
};
|
|
85
|
+
req: HSRequest;
|
|
86
|
+
res: HSResponse;
|
|
54
87
|
};
|
|
55
88
|
|
|
56
89
|
export type ClientIslandOptions = {
|
|
@@ -94,7 +127,6 @@ export namespace Hyperspan {
|
|
|
94
127
|
|
|
95
128
|
export interface Route {
|
|
96
129
|
_kind: 'hsRoute';
|
|
97
|
-
_name: string | undefined;
|
|
98
130
|
_config: Hyperspan.RouteConfig;
|
|
99
131
|
_path(): string;
|
|
100
132
|
_methods(): string[];
|
|
@@ -104,7 +136,29 @@ export namespace Hyperspan {
|
|
|
104
136
|
patch: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
|
|
105
137
|
delete: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
|
|
106
138
|
options: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
|
|
139
|
+
errorHandler: (handler: Hyperspan.RouteHandler) => Hyperspan.Route;
|
|
107
140
|
middleware: (middleware: Array<Hyperspan.MiddlewareFunction>) => Hyperspan.Route;
|
|
108
141
|
fetch: (request: Request) => Promise<Response>;
|
|
109
142
|
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Action = Form + route handler
|
|
146
|
+
*/
|
|
147
|
+
export type ActionResponse = HSHtml | void | null | Promise<HSHtml | void | null> | Response | Promise<Response>;
|
|
148
|
+
export type ActionProps<T extends z.ZodTypeAny> = { data?: Partial<z.infer<T>>; error?: z.ZodError | Error };
|
|
149
|
+
export type ActionFormHandler<T extends z.ZodTypeAny> = (
|
|
150
|
+
c: Context, props: ActionProps<T>
|
|
151
|
+
) => ActionResponse;
|
|
152
|
+
export interface Action<T extends z.ZodTypeAny> {
|
|
153
|
+
_kind: 'hsAction';
|
|
154
|
+
_config: Hyperspan.RouteConfig;
|
|
155
|
+
_path(): string;
|
|
156
|
+
_form: null | ActionFormHandler<T>;
|
|
157
|
+
form(form: ActionFormHandler<T>): Action<T>;
|
|
158
|
+
render: (c: Context, props?: ActionProps<T>) => ActionResponse;
|
|
159
|
+
post: (handler: ActionFormHandler<T>) => Action<T>;
|
|
160
|
+
errorHandler: (handler: ActionFormHandler<T>) => Action<T>;
|
|
161
|
+
middleware: (middleware: Array<Hyperspan.MiddlewareFunction>) => Action<T>;
|
|
162
|
+
fetch: (request: Request) => Promise<Response>;
|
|
163
|
+
}
|
|
110
164
|
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { test, expect, describe } from 'bun:test';
|
|
2
|
+
import { formDataToJSON, parsePath } from './utils';
|
|
3
|
+
|
|
4
|
+
describe('formDataToJSON', () => {
|
|
5
|
+
test('formDataToJSON returns empty object for empty FormData', () => {
|
|
6
|
+
const formData = new FormData();
|
|
7
|
+
const result = formDataToJSON(formData);
|
|
8
|
+
|
|
9
|
+
expect(result).toEqual({});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('formDataToJSON handles simple FormData object', () => {
|
|
13
|
+
const formData = new FormData();
|
|
14
|
+
formData.append('name', 'John Doe');
|
|
15
|
+
formData.append('email', 'john@example.com');
|
|
16
|
+
formData.append('age', '30');
|
|
17
|
+
|
|
18
|
+
const result = formDataToJSON(formData);
|
|
19
|
+
|
|
20
|
+
expect(result).toEqual({
|
|
21
|
+
name: 'John Doe',
|
|
22
|
+
email: 'john@example.com',
|
|
23
|
+
age: '30',
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('formDataToJSON handles complex FormData with nested fields', () => {
|
|
28
|
+
const formData = new FormData();
|
|
29
|
+
formData.append('user[firstName]', 'John');
|
|
30
|
+
formData.append('user[lastName]', 'Doe');
|
|
31
|
+
formData.append('user[email]', 'john@example.com');
|
|
32
|
+
formData.append('user[address][street]', '123 Main St');
|
|
33
|
+
formData.append('user[address][city]', 'New York');
|
|
34
|
+
formData.append('user[address][zip]', '10001');
|
|
35
|
+
|
|
36
|
+
const result = formDataToJSON(formData);
|
|
37
|
+
|
|
38
|
+
expect(result).toEqual({
|
|
39
|
+
user: {
|
|
40
|
+
firstName: 'John',
|
|
41
|
+
lastName: 'Doe',
|
|
42
|
+
email: 'john@example.com',
|
|
43
|
+
address: {
|
|
44
|
+
street: '123 Main St',
|
|
45
|
+
city: 'New York',
|
|
46
|
+
zip: '10001',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
} as any);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('formDataToJSON handles FormData with array of values', () => {
|
|
53
|
+
const formData = new FormData();
|
|
54
|
+
formData.append('tags', 'javascript');
|
|
55
|
+
formData.append('tags', 'typescript');
|
|
56
|
+
formData.append('tags', 'nodejs');
|
|
57
|
+
formData.append('colors[]', 'red');
|
|
58
|
+
formData.append('colors[]', 'green');
|
|
59
|
+
formData.append('colors[]', 'blue');
|
|
60
|
+
|
|
61
|
+
const result = formDataToJSON(formData);
|
|
62
|
+
|
|
63
|
+
expect(result).toEqual({
|
|
64
|
+
tags: ['javascript', 'typescript', 'nodejs'],
|
|
65
|
+
colors: ['red', 'green', 'blue'],
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('parsePath', () => {
|
|
71
|
+
test('parsePath returns root path for empty string', () => {
|
|
72
|
+
const result = parsePath('');
|
|
73
|
+
expect(result.path).toBe('/');
|
|
74
|
+
expect(result.params).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('parsePath handles simple path', () => {
|
|
78
|
+
const result = parsePath('users');
|
|
79
|
+
expect(result.path).toBe('/users');
|
|
80
|
+
expect(result.params).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('parsePath removes leading slash', () => {
|
|
84
|
+
const result = parsePath('/users');
|
|
85
|
+
expect(result.path).toBe('/users');
|
|
86
|
+
expect(result.params).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('parsePath removes trailing slash', () => {
|
|
90
|
+
const result = parsePath('users/');
|
|
91
|
+
expect(result.path).toBe('/users');
|
|
92
|
+
expect(result.params).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('parsePath removes both leading and trailing slashes', () => {
|
|
96
|
+
const result = parsePath('/users/');
|
|
97
|
+
expect(result.path).toBe('/users');
|
|
98
|
+
expect(result.params).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('parsePath handles nested paths', () => {
|
|
102
|
+
const result = parsePath('users/posts');
|
|
103
|
+
expect(result.path).toBe('/users/posts');
|
|
104
|
+
expect(result.params).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('parsePath lowercases path segments', () => {
|
|
108
|
+
const result = parsePath('Users/Posts');
|
|
109
|
+
expect(result.path).toBe('/users/posts');
|
|
110
|
+
expect(result.params).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('parsePath removes .ts extension', () => {
|
|
114
|
+
const result = parsePath('users.ts');
|
|
115
|
+
expect(result.path).toBe('/users');
|
|
116
|
+
expect(result.params).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('parsePath removes .js extension', () => {
|
|
120
|
+
const result = parsePath('users.js');
|
|
121
|
+
expect(result.path).toBe('/users');
|
|
122
|
+
expect(result.params).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('parsePath removes index from path', () => {
|
|
126
|
+
const result = parsePath('index');
|
|
127
|
+
expect(result.path).toBe('/');
|
|
128
|
+
expect(result.params).toEqual([]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('parsePath removes index.ts from path', () => {
|
|
132
|
+
const result = parsePath('index.ts');
|
|
133
|
+
expect(result.path).toBe('/');
|
|
134
|
+
expect(result.params).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('parsePath handles dynamic param with brackets', () => {
|
|
138
|
+
const result = parsePath('users/[userId]');
|
|
139
|
+
expect(result.path).toBe('/users/:userId');
|
|
140
|
+
expect(result.params).toEqual(['userId']);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('parsePath handles multiple dynamic params', () => {
|
|
144
|
+
const result = parsePath('users/[userId]/posts/[postId]');
|
|
145
|
+
expect(result.path).toBe('/users/:userId/posts/:postId');
|
|
146
|
+
expect(result.params).toEqual(['userId', 'postId']);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('parsePath handles catch-all param with spread', () => {
|
|
150
|
+
const result = parsePath('users/[...slug]');
|
|
151
|
+
expect(result.path).toBe('/users/*');
|
|
152
|
+
expect(result.params).toEqual(['slug']);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('parsePath handles catch-all param at root', () => {
|
|
156
|
+
const result = parsePath('[...slug]');
|
|
157
|
+
expect(result.path).toBe('/*');
|
|
158
|
+
expect(result.params).toEqual(['slug']);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('parsePath preserves param names in path but converts format', () => {
|
|
162
|
+
const result = parsePath('users/[userId]');
|
|
163
|
+
expect(result.path).toBe('/users/:userId');
|
|
164
|
+
expect(result.params).toEqual(['userId']);
|
|
165
|
+
// Param segment should not be lowercased
|
|
166
|
+
expect(result.path).toContain(':userId');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('parsePath handles complex nested path with params', () => {
|
|
170
|
+
const result = parsePath('/api/users/[userId]/posts/[postId]/comments');
|
|
171
|
+
expect(result.path).toBe('/api/users/:userId/posts/:postId/comments');
|
|
172
|
+
expect(result.params).toEqual(['userId', 'postId']);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('parsePath handles path with dots in param name', () => {
|
|
176
|
+
const result = parsePath('users/[user.id]');
|
|
177
|
+
expect(result.path).toBe('/users/:user.id');
|
|
178
|
+
expect(result.params).toEqual(['user.id']);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('parsePath handles mixed case with params', () => {
|
|
182
|
+
const result = parsePath('Users/[UserId]/Posts');
|
|
183
|
+
expect(result.path).toBe('/users/:UserId/posts');
|
|
184
|
+
expect(result.params).toEqual(['UserId']);
|
|
185
|
+
// Non-param segments should be lowercased, but param name preserved
|
|
186
|
+
expect(result.path).toContain('/users/');
|
|
187
|
+
expect(result.path).toContain('/posts');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('parsePath handles file path format', () => {
|
|
191
|
+
const result = parsePath('/routes/users/[userId].ts');
|
|
192
|
+
expect(result.path).toBe('/routes/users/:userId');
|
|
193
|
+
expect(result.params).toEqual(['userId']);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
package/src/utils.ts
CHANGED
|
@@ -1,5 +1,139 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
|
|
3
3
|
export function assetHash(content: string): string {
|
|
4
4
|
return createHash('md5').update(content).digest('hex');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function randomHash(): string {
|
|
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, '').replace('...', '');
|
|
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;
|
|
5
139
|
}
|