@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/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
- export type { HS as Hyperspan };
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 HTTPException extends Error {
11
- constructor(public status: number, message?: string) {
12
- super(message);
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
- appDir: config.appDir ?? './app',
23
- publicDir: config.publicDir ?? './public',
24
- plugins: [clientJSPlugin(), ...(config.plugins ?? [])],
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
- headers: new Headers(),
98
+ cookies: new Cookies(req, headers),
99
+ headers,
60
100
  raw: new Response(),
61
- 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 } }),
62
- json: (json: any, options?: { status?: number; headers?: Headers | Record<string, string> }) => new Response(JSON.stringify(json), { ...options, headers: { 'Content-Type': 'application/json', ...options?.headers } }),
63
- 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 } }),
64
- redirect: (url: string, options?: { status?: number; headers?: Headers | Record<string, string> }) => new Response(null, { status: 302, headers: { Location: url, ...options?.headers } }),
65
- error: (error: Error, options?: { status?: number; headers?: Headers | Record<string, string> }) => new Response(error.message, { status: 500, ...options }),
66
- notFound: (options?: { status?: number; headers?: Headers | Record<string, string> }) => new Response('Not Found', { status: 404, ...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
- errorHandler(handler: HS.RouteHandler) {
142
- _handlers['_ERROR'] = handler;
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
- return returnHTMLResponse(context, () => routeContent);
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 (_handlers['_ERROR']) {
212
- return await (_handlers['_ERROR'](context) as Promise<Response>);
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
- // @TODO: Move this to config or something...
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 (streamingEnabled && (routeContent as HSHtml).asyncContent?.length > 0) {
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 HTTPException) {
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
- path: string;
36
- params: Record<string, string>;
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?: string;
69
- path?: string;
70
- cssImports?: string[];
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.RouteHandler) => Hyperspan.Route;
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
+