@hyperspan/framework 1.0.0-alpha.8 → 1.0.0
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 +3 -6
- package/src/actions.test.ts +24 -83
- package/src/actions.ts +12 -7
- package/src/client/_hs/hyperspan-streaming.client.ts +36 -64
- package/src/client/js.test.ts +200 -0
- package/src/client/js.ts +76 -43
- package/src/cookies.ts +234 -0
- package/src/index.ts +1 -1
- package/src/layout.ts +24 -1
- package/src/middleware.ts +87 -1
- package/src/server.test.ts +116 -1
- package/src/server.ts +101 -41
- package/src/types.ts +83 -32
- package/src/utils.test.ts +196 -0
- package/src/plugins.ts +0 -94
package/src/server.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
import { HSHtml, html, isHSHtml, renderStream, renderAsync, render } from '@hyperspan/html';
|
|
1
|
+
import { HSHtml, html, isHSHtml, renderStream, renderAsync, render, _typeOf } from '@hyperspan/html';
|
|
2
|
+
import { isbot } from 'isbot';
|
|
2
3
|
import { executeMiddleware } from './middleware';
|
|
3
|
-
import type { Hyperspan as HS } from './types';
|
|
4
|
-
import { clientJSPlugin } from './plugins';
|
|
5
4
|
import { parsePath } from './utils';
|
|
6
|
-
|
|
5
|
+
import { Cookies } from './cookies';
|
|
6
|
+
|
|
7
|
+
import type { Hyperspan as HS } from './types';
|
|
7
8
|
|
|
8
9
|
export const IS_PROD = process.env.NODE_ENV === 'production';
|
|
9
10
|
|
|
10
|
-
export class
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
export class HTTPResponseException extends Error {
|
|
12
|
+
public _error?: Error;
|
|
13
|
+
public _response?: Response;
|
|
14
|
+
constructor(body: string | Error | undefined, options?: ResponseInit) {
|
|
15
|
+
super(body instanceof Error ? body.message : body);
|
|
16
|
+
this._error = body instanceof Error ? body : undefined;
|
|
17
|
+
this._response = new Response(body instanceof Error ? body.message : body, options);
|
|
13
18
|
}
|
|
14
19
|
}
|
|
15
20
|
|
|
@@ -17,11 +22,22 @@ export class HTTPException extends Error {
|
|
|
17
22
|
* Ensures a valid config object is returned, even with an empty object or partial object passed in
|
|
18
23
|
*/
|
|
19
24
|
export function createConfig(config: Partial<HS.Config> = {}): HS.Config {
|
|
25
|
+
const defaultConfig: HS.Config = {
|
|
26
|
+
appDir: './app',
|
|
27
|
+
publicDir: './public',
|
|
28
|
+
plugins: [],
|
|
29
|
+
responseOptions: {
|
|
30
|
+
// Disable streaming for bots by default
|
|
31
|
+
disableStreaming: (c) => isbot(c.req.raw.headers.get('user-agent') ?? ''),
|
|
32
|
+
},
|
|
33
|
+
};
|
|
20
34
|
return {
|
|
35
|
+
...defaultConfig,
|
|
21
36
|
...config,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
37
|
+
responseOptions: {
|
|
38
|
+
...defaultConfig.responseOptions,
|
|
39
|
+
...config.responseOptions,
|
|
40
|
+
},
|
|
25
41
|
};
|
|
26
42
|
}
|
|
27
43
|
|
|
@@ -35,11 +51,33 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
35
51
|
const headers = new Headers(req.headers);
|
|
36
52
|
const path = route?._path() || '/';
|
|
37
53
|
// @ts-ignore - Bun will put 'params' on the Request object even though it's not standardized
|
|
38
|
-
const params: HS.RouteParamsParser<path> = req?.params || {};
|
|
54
|
+
const params: HS.RouteParamsParser<path> & Record<string, string | undefined> = Object.assign({}, req?.params || {}, route?._config.params || {});
|
|
55
|
+
|
|
56
|
+
// Replace catch-all param with the value from the URL path
|
|
57
|
+
const catchAllParam = Object.keys(params).find(key => key.startsWith('...'));
|
|
58
|
+
if (catchAllParam && path.includes('/*')) {
|
|
59
|
+
const catchAllValue = url.pathname.split(path.replace('/*', '/')).pop();
|
|
60
|
+
params[catchAllParam.replace('...', '')] = catchAllValue;
|
|
61
|
+
delete params[catchAllParam];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const merge = (response: Response) => {
|
|
65
|
+
// Convert headers to plain objects and merge (response headers override context headers)
|
|
66
|
+
const mergedHeaders = {
|
|
67
|
+
...Object.fromEntries(headers.entries()),
|
|
68
|
+
...Object.fromEntries(response.headers.entries()),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return new Response(response.body, {
|
|
72
|
+
status: response.status,
|
|
73
|
+
headers: mergedHeaders,
|
|
74
|
+
});
|
|
75
|
+
};
|
|
39
76
|
|
|
40
77
|
return {
|
|
41
78
|
vars: {},
|
|
42
79
|
route: {
|
|
80
|
+
name: route?._config.name || undefined,
|
|
43
81
|
path,
|
|
44
82
|
params: params,
|
|
45
83
|
cssImports: route ? route._config.cssImports ?? [] : [],
|
|
@@ -50,20 +88,23 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
50
88
|
method,
|
|
51
89
|
headers,
|
|
52
90
|
query,
|
|
91
|
+
cookies: new Cookies(req),
|
|
53
92
|
async text() { return req.text() },
|
|
54
93
|
async json<T = unknown>() { return await req.json() as T },
|
|
55
94
|
async formData<T = unknown>() { return await req.formData() as T },
|
|
56
95
|
async urlencoded() { return new URLSearchParams(await req.text()) },
|
|
57
96
|
},
|
|
58
97
|
res: {
|
|
59
|
-
|
|
98
|
+
cookies: new Cookies(req, headers),
|
|
99
|
+
headers,
|
|
60
100
|
raw: new Response(),
|
|
61
|
-
html: (html: string, options?:
|
|
62
|
-
json: (json: any, options?:
|
|
63
|
-
text: (text: string, options?:
|
|
64
|
-
redirect: (url: string, options?:
|
|
65
|
-
error: (error: Error, options?:
|
|
66
|
-
notFound: (options?:
|
|
101
|
+
html: (html: string, options?: ResponseInit) => merge(new Response(html, { ...options, headers: { 'Content-Type': 'text/html; charset=UTF-8', ...options?.headers } })),
|
|
102
|
+
json: (json: any, options?: ResponseInit) => merge(new Response(JSON.stringify(json), { ...options, headers: { 'Content-Type': 'application/json', ...options?.headers } })),
|
|
103
|
+
text: (text: string, options?: ResponseInit) => merge(new Response(text, { ...options, headers: { 'Content-Type': 'text/plain; charset=UTF-8', ...options?.headers } })),
|
|
104
|
+
redirect: (url: string, options?: ResponseInit) => merge(new Response(null, { status: 302, headers: { Location: url, ...options?.headers } })),
|
|
105
|
+
error: (error: Error, options?: ResponseInit) => merge(new Response(error.message, { status: 500, ...options })),
|
|
106
|
+
notFound: (options?: ResponseInit) => merge(new Response('Not Found', { status: 404, ...options })),
|
|
107
|
+
merge,
|
|
67
108
|
},
|
|
68
109
|
};
|
|
69
110
|
}
|
|
@@ -73,13 +114,13 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
73
114
|
* Define a route that can handle a direct HTTP request.
|
|
74
115
|
* Route handlers should return a HSHtml or Response object
|
|
75
116
|
*/
|
|
76
|
-
export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
117
|
+
export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
|
|
77
118
|
const _handlers: Record<string, HS.RouteHandler> = {};
|
|
119
|
+
let _errorHandler: HS.ErrorHandler | undefined = undefined;
|
|
78
120
|
let _middleware: Record<string, Array<HS.MiddlewareFunction>> = { '*': [] };
|
|
79
121
|
|
|
80
122
|
const api: HS.Route = {
|
|
81
123
|
_kind: 'hsRoute',
|
|
82
|
-
_name: config.name,
|
|
83
124
|
_config: config,
|
|
84
125
|
_methods: () => Object.keys(_handlers),
|
|
85
126
|
_path() {
|
|
@@ -114,14 +155,6 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
|
114
155
|
_middleware['PUT'] = handlerOptions?.middleware || [];
|
|
115
156
|
return api;
|
|
116
157
|
},
|
|
117
|
-
/**
|
|
118
|
-
* Add a DELETE route handler (typically to delete existing data)
|
|
119
|
-
*/
|
|
120
|
-
delete(handler: HS.RouteHandler, handlerOptions?: HS.RouteHandlerOptions) {
|
|
121
|
-
_handlers['DELETE'] = handler;
|
|
122
|
-
_middleware['DELETE'] = handlerOptions?.middleware || [];
|
|
123
|
-
return api;
|
|
124
|
-
},
|
|
125
158
|
/**
|
|
126
159
|
* Add a PATCH route handler (typically to update existing data)
|
|
127
160
|
*/
|
|
@@ -130,6 +163,14 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
|
130
163
|
_middleware['PATCH'] = handlerOptions?.middleware || [];
|
|
131
164
|
return api;
|
|
132
165
|
},
|
|
166
|
+
/**
|
|
167
|
+
* Add a DELETE route handler (typically to delete existing data)
|
|
168
|
+
*/
|
|
169
|
+
delete(handler: HS.RouteHandler, handlerOptions?: HS.RouteHandlerOptions) {
|
|
170
|
+
_handlers['DELETE'] = handler;
|
|
171
|
+
_middleware['DELETE'] = handlerOptions?.middleware || [];
|
|
172
|
+
return api;
|
|
173
|
+
},
|
|
133
174
|
/**
|
|
134
175
|
* Add a OPTIONS route handler (typically to handle CORS preflight requests)
|
|
135
176
|
*/
|
|
@@ -138,8 +179,11 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
|
138
179
|
_middleware['OPTIONS'] = handlerOptions?.middleware || [];
|
|
139
180
|
return api;
|
|
140
181
|
},
|
|
141
|
-
|
|
142
|
-
|
|
182
|
+
/**
|
|
183
|
+
* Set a custom error handler for this route to fall back to if the route handler throws an error
|
|
184
|
+
*/
|
|
185
|
+
errorHandler(handler: HS.ErrorHandler) {
|
|
186
|
+
_errorHandler = handler;
|
|
143
187
|
return api;
|
|
144
188
|
},
|
|
145
189
|
/**
|
|
@@ -197,7 +241,14 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
|
197
241
|
}
|
|
198
242
|
|
|
199
243
|
if (isHTMLContent(routeContent)) {
|
|
200
|
-
|
|
244
|
+
// Merge server and route-specific response options
|
|
245
|
+
const responseOptions = { ...(api._serverConfig?.responseOptions ?? {}), ...(api._config?.responseOptions ?? {}) };
|
|
246
|
+
return returnHTMLResponse(context, () => routeContent, responseOptions);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const contentType = _typeOf(routeContent);
|
|
250
|
+
if (contentType === 'generator') {
|
|
251
|
+
return new StreamResponse(routeContent as AsyncGenerator);
|
|
201
252
|
}
|
|
202
253
|
|
|
203
254
|
return routeContent;
|
|
@@ -208,8 +259,9 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
|
208
259
|
try {
|
|
209
260
|
return await executeMiddleware(context, [...globalMiddleware, ...methodMiddleware, methodHandler]);
|
|
210
261
|
} catch (e) {
|
|
211
|
-
if (
|
|
212
|
-
|
|
262
|
+
if (_errorHandler !== undefined) {
|
|
263
|
+
const responseOptions = { ...(api._serverConfig?.responseOptions ?? {}), ...(api._config?.responseOptions ?? {}) };
|
|
264
|
+
return returnHTMLResponse(context, () => (_errorHandler as HS.ErrorHandler)(context, e as Error), responseOptions);
|
|
213
265
|
}
|
|
214
266
|
throw e;
|
|
215
267
|
}
|
|
@@ -297,7 +349,7 @@ function isHTMLContent(response: unknown): response is Response {
|
|
|
297
349
|
export async function returnHTMLResponse(
|
|
298
350
|
context: HS.Context,
|
|
299
351
|
handlerFn: () => unknown,
|
|
300
|
-
responseOptions?: { status?: number; headers?: Record<string, string
|
|
352
|
+
responseOptions?: { status?: number; headers?: Record<string, string>; disableStreaming?: (context: HS.Context) => boolean }
|
|
301
353
|
): Promise<Response> {
|
|
302
354
|
try {
|
|
303
355
|
const routeContent = await handlerFn();
|
|
@@ -309,14 +361,22 @@ export async function returnHTMLResponse(
|
|
|
309
361
|
|
|
310
362
|
// Render HSHtml if returned from route handler
|
|
311
363
|
if (isHSHtml(routeContent)) {
|
|
312
|
-
|
|
313
|
-
const streamOpt = context.req.query.get('__nostream');
|
|
314
|
-
const streamingEnabled = (streamOpt !== undefined ? streamOpt : true);
|
|
364
|
+
const disableStreaming = responseOptions?.disableStreaming?.(context) ?? false;
|
|
315
365
|
|
|
316
366
|
// Stream only if enabled and there is async content to stream
|
|
317
|
-
if (
|
|
367
|
+
if (!disableStreaming && (routeContent as HSHtml).asyncContent?.length > 0) {
|
|
318
368
|
return new StreamResponse(
|
|
319
|
-
renderStream(routeContent as HSHtml
|
|
369
|
+
renderStream(routeContent as HSHtml, {
|
|
370
|
+
renderChunk: (chunk) => {
|
|
371
|
+
return html`
|
|
372
|
+
<template id="${chunk.id}_content">${html.raw(chunk.content)}<!--end--></template>
|
|
373
|
+
<script>
|
|
374
|
+
window._hsc = window._hsc || [];
|
|
375
|
+
window._hsc.push({id: "${chunk.id}" });
|
|
376
|
+
</script>
|
|
377
|
+
`;
|
|
378
|
+
}
|
|
379
|
+
}),
|
|
320
380
|
responseOptions
|
|
321
381
|
) as Response;
|
|
322
382
|
} else {
|
|
@@ -382,8 +442,8 @@ async function showErrorReponse(
|
|
|
382
442
|
const message = err.message || 'Internal Server Error';
|
|
383
443
|
|
|
384
444
|
// Send correct status code if HTTPException
|
|
385
|
-
if (err instanceof
|
|
386
|
-
status = err.status;
|
|
445
|
+
if (err instanceof HTTPResponseException) {
|
|
446
|
+
status = err._response?.status ?? 500;
|
|
387
447
|
}
|
|
388
448
|
|
|
389
449
|
const stack = !IS_PROD && err.stack ? err.stack.split('\n').slice(1).join('\n') : '';
|
package/src/types.ts
CHANGED
|
@@ -27,36 +27,62 @@ export namespace Hyperspan {
|
|
|
27
27
|
// For customizing the routes and adding your own...
|
|
28
28
|
beforeRoutesAdded?: (server: Hyperspan.Server) => void;
|
|
29
29
|
afterRoutesAdded?: (server: Hyperspan.Server) => void;
|
|
30
|
+
responseOptions?: {
|
|
31
|
+
disableStreaming?: (context: Hyperspan.Context) => boolean;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type CookieOptions = {
|
|
36
|
+
maxAge?: number;
|
|
37
|
+
domain?: string;
|
|
38
|
+
path?: string;
|
|
39
|
+
expires?: Date;
|
|
40
|
+
httpOnly?: boolean;
|
|
41
|
+
secure?: boolean;
|
|
42
|
+
sameSite?: 'lax' | 'strict' | true;
|
|
43
|
+
};
|
|
44
|
+
export type Cookies = {
|
|
45
|
+
_req: Request;
|
|
46
|
+
_responseHeaders: Headers | undefined;
|
|
47
|
+
_parsedCookies: Record<string, any>;
|
|
48
|
+
_encrypt: ((str: string) => string) | undefined;
|
|
49
|
+
_decrypt: ((str: string) => string) | undefined;
|
|
50
|
+
get: (name: string) => string | undefined;
|
|
51
|
+
set: (name: string, value: string, options?: CookieOptions) => void;
|
|
52
|
+
delete: (name: string) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type HSRequest = {
|
|
56
|
+
url: URL;
|
|
57
|
+
raw: Request;
|
|
58
|
+
method: string; // Always uppercase
|
|
59
|
+
headers: Headers; // Case-insensitive
|
|
60
|
+
query: URLSearchParams;
|
|
61
|
+
cookies: Hyperspan.Cookies;
|
|
62
|
+
text: () => Promise<string>;
|
|
63
|
+
json<T = unknown>(): Promise<T>;
|
|
64
|
+
formData(): Promise<FormData>;
|
|
65
|
+
urlencoded(): Promise<URLSearchParams>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type HSResponse = {
|
|
69
|
+
cookies: Hyperspan.Cookies;
|
|
70
|
+
headers: Headers; // Headers to merge with final outgoing response
|
|
71
|
+
html: (html: string, options?: ResponseInit) => Response
|
|
72
|
+
json: (json: any, options?: ResponseInit) => Response;
|
|
73
|
+
text: (text: string, options?: ResponseInit) => Response;
|
|
74
|
+
redirect: (url: string, options?: ResponseInit) => Response;
|
|
75
|
+
error: (error: Error, options?: ResponseInit) => Response;
|
|
76
|
+
notFound: (options?: ResponseInit) => Response;
|
|
77
|
+
merge: (response: Response) => Response;
|
|
78
|
+
raw: Response;
|
|
30
79
|
};
|
|
31
80
|
|
|
32
81
|
export interface Context {
|
|
33
82
|
vars: Record<string, any>;
|
|
34
|
-
route:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
cssImports?: string[];
|
|
38
|
-
}
|
|
39
|
-
req: {
|
|
40
|
-
url: URL;
|
|
41
|
-
raw: Request;
|
|
42
|
-
method: string; // Always uppercase
|
|
43
|
-
headers: Headers; // Case-insensitive
|
|
44
|
-
query: URLSearchParams;
|
|
45
|
-
text: () => Promise<string>;
|
|
46
|
-
json<T = unknown>(): Promise<T>;
|
|
47
|
-
formData<T = unknown>(): Promise<T>;
|
|
48
|
-
urlencoded(): Promise<URLSearchParams>;
|
|
49
|
-
};
|
|
50
|
-
res: {
|
|
51
|
-
headers: Headers; // Headers to merge with final outgoing response
|
|
52
|
-
html: (html: string, options?: { status?: number; headers?: Record<string, string> }) => Response
|
|
53
|
-
json: (json: any, options?: { status?: number; headers?: Record<string, string> }) => Response;
|
|
54
|
-
text: (text: string, options?: { status?: number; headers?: Record<string, string> }) => Response;
|
|
55
|
-
redirect: (url: string, options?: { status?: number; headers?: Record<string, string> }) => Response;
|
|
56
|
-
error: (error: Error, options?: { status?: number; headers?: Record<string, string> }) => Response;
|
|
57
|
-
notFound: (options?: { status?: number; headers?: Record<string, string> }) => Response;
|
|
58
|
-
raw: Response;
|
|
59
|
-
};
|
|
83
|
+
route: RouteConfig;
|
|
84
|
+
req: HSRequest;
|
|
85
|
+
res: HSResponse;
|
|
60
86
|
};
|
|
61
87
|
|
|
62
88
|
export type ClientIslandOptions = {
|
|
@@ -65,9 +91,13 @@ export namespace Hyperspan {
|
|
|
65
91
|
};
|
|
66
92
|
|
|
67
93
|
export type RouteConfig = {
|
|
68
|
-
name
|
|
69
|
-
path
|
|
70
|
-
|
|
94
|
+
name: string | undefined;
|
|
95
|
+
path: string;
|
|
96
|
+
params: Record<string, string | undefined>;
|
|
97
|
+
cssImports: string[];
|
|
98
|
+
responseOptions?: {
|
|
99
|
+
disableStreaming?: (context: Hyperspan.Context) => boolean;
|
|
100
|
+
};
|
|
71
101
|
};
|
|
72
102
|
export type RouteHandler = (context: Hyperspan.Context) => unknown;
|
|
73
103
|
export type RouteHandlerOptions = {
|
|
@@ -89,6 +119,11 @@ export namespace Hyperspan {
|
|
|
89
119
|
*/
|
|
90
120
|
export type NextFunction = () => Promise<Response>;
|
|
91
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Error handler function signature
|
|
124
|
+
*/
|
|
125
|
+
export type ErrorHandler = (context: Hyperspan.Context, error: Error) => unknown | undefined;
|
|
126
|
+
|
|
92
127
|
/**
|
|
93
128
|
* Middleware function signature
|
|
94
129
|
* Accepts context and next function, returns a Response
|
|
@@ -100,7 +135,8 @@ export namespace Hyperspan {
|
|
|
100
135
|
|
|
101
136
|
export interface Route {
|
|
102
137
|
_kind: 'hsRoute';
|
|
103
|
-
_config: Hyperspan.RouteConfig
|
|
138
|
+
_config: Partial<Hyperspan.RouteConfig>;
|
|
139
|
+
_serverConfig?: Hyperspan.Config;
|
|
104
140
|
_path(): string;
|
|
105
141
|
_methods(): string[];
|
|
106
142
|
get: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
|
|
@@ -109,7 +145,7 @@ export namespace Hyperspan {
|
|
|
109
145
|
patch: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
|
|
110
146
|
delete: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
|
|
111
147
|
options: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
|
|
112
|
-
errorHandler: (handler: Hyperspan.
|
|
148
|
+
errorHandler: (handler: Hyperspan.ErrorHandler) => Hyperspan.Route;
|
|
113
149
|
middleware: (middleware: Array<Hyperspan.MiddlewareFunction>) => Hyperspan.Route;
|
|
114
150
|
fetch: (request: Request) => Promise<Response>;
|
|
115
151
|
};
|
|
@@ -124,7 +160,7 @@ export namespace Hyperspan {
|
|
|
124
160
|
) => ActionResponse;
|
|
125
161
|
export interface Action<T extends z.ZodTypeAny> {
|
|
126
162
|
_kind: 'hsAction';
|
|
127
|
-
_config: Hyperspan.RouteConfig
|
|
163
|
+
_config: Partial<Hyperspan.RouteConfig>;
|
|
128
164
|
_path(): string;
|
|
129
165
|
_form: null | ActionFormHandler<T>;
|
|
130
166
|
form(form: ActionFormHandler<T>): Action<T>;
|
|
@@ -134,4 +170,19 @@ export namespace Hyperspan {
|
|
|
134
170
|
middleware: (middleware: Array<Hyperspan.MiddlewareFunction>) => Action<T>;
|
|
135
171
|
fetch: (request: Request) => Promise<Response>;
|
|
136
172
|
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Client JS Module = ESM Module + Public Path + Render Script Tag
|
|
176
|
+
*/
|
|
177
|
+
export type ClientJSBuildResult = {
|
|
178
|
+
assetHash: string; // Asset hash of the module path
|
|
179
|
+
esmName: string; // Filename of the built JavaScript file without the extension
|
|
180
|
+
publicPath: string; // Full public path of the built JavaScript file
|
|
181
|
+
/**
|
|
182
|
+
* Render a <script type="module"> tag for the JS module
|
|
183
|
+
* @param loadScript - A function that loads the module or a string of code to load the module
|
|
184
|
+
* @returns HSHtml Template with the <script type="module"> tag
|
|
185
|
+
*/
|
|
186
|
+
renderScriptTag: (loadScript?: ((module: unknown) => HSHtml | string) | string) => HSHtml;
|
|
187
|
+
}
|
|
137
188
|
}
|
|
@@ -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
|
+
|