@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/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
- export type { HS as Hyperspan };
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 HTTPException extends Error {
11
- constructor(public status: number, message?: string) {
12
- super(message);
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
- body: req.body,
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?: { status?: number; headers?: Headers | Record<string, string> }) => new Response(html, { ...options, headers: { 'Content-Type': 'text/html; charset=UTF-8', ...options?.headers } }),
56
- json: (json: any, options?: { status?: number; headers?: Headers | Record<string, string> }) => new Response(JSON.stringify(json), { ...options, headers: { 'Content-Type': 'application/json', ...options?.headers } }),
57
- text: (text: string, options?: { status?: number; headers?: Headers | Record<string, string> }) => new Response(text, { ...options, headers: { 'Content-Type': 'text/plain; charset=UTF-8', ...options?.headers } }),
58
- redirect: (url: string, options?: { status?: number; headers?: Headers | Record<string, string> }) => new Response(null, { status: 302, headers: { Location: url, ...options?.headers } }),
59
- error: (error: Error, options?: { status?: number; headers?: Headers | Record<string, string> }) => new Response(error.message, { status: 500, ...options }),
60
- notFound: (options?: { status?: number; headers?: Headers | Record<string, string> }) => new Response('Not Found', { status: 404, ...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[context.req.method] || [];
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
- return executeMiddleware(context, [...globalMiddleware, ...methodMiddleware, methodHandler]);
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 'hsRoute' === obj?._kind && 'fetch' in obj;
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 HTTPException) {
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
- url: URL;
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
  }