@hyperspan/framework 1.0.0-alpha.7 → 1.0.0-alpha.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspan/framework",
3
- "version": "1.0.0-alpha.7",
3
+ "version": "1.0.0-alpha.9",
4
4
  "description": "Hyperspan Web Framework",
5
5
  "main": "src/server.ts",
6
6
  "types": "src/server.ts",
@@ -1,92 +1,33 @@
1
1
  import { test, expect, describe } from 'bun:test';
2
- import { formDataToJSON, createAction } from './actions';
2
+ import { createAction } from './actions';
3
3
  import { html, render, type HSHtml } from '@hyperspan/html';
4
4
  import { createContext } from './server';
5
5
  import type { Hyperspan as HS } from './types';
6
6
  import * as z from 'zod/v4';
7
7
 
8
- describe('formDataToJSON', () => {
9
- test('formDataToJSON returns empty object for empty FormData', () => {
10
- const formData = new FormData();
11
- const result = formDataToJSON(formData);
12
-
13
- expect(result).toEqual({});
14
- });
15
-
16
- test('formDataToJSON handles simple FormData object', () => {
17
- const formData = new FormData();
18
- formData.append('name', 'John Doe');
19
- formData.append('email', 'john@example.com');
20
- formData.append('age', '30');
21
-
22
- const result = formDataToJSON(formData);
23
-
24
- expect(result).toEqual({
25
- name: 'John Doe',
26
- email: 'john@example.com',
27
- age: '30',
28
- });
29
- });
30
-
31
- test('formDataToJSON handles complex FormData with nested fields', () => {
32
- const formData = new FormData();
33
- formData.append('user[firstName]', 'John');
34
- formData.append('user[lastName]', 'Doe');
35
- formData.append('user[email]', 'john@example.com');
36
- formData.append('user[address][street]', '123 Main St');
37
- formData.append('user[address][city]', 'New York');
38
- formData.append('user[address][zip]', '10001');
39
-
40
- const result = formDataToJSON(formData);
41
-
42
- expect(result).toEqual({
43
- user: {
44
- firstName: 'John',
45
- lastName: 'Doe',
46
- email: 'john@example.com',
47
- address: {
48
- street: '123 Main St',
49
- city: 'New York',
50
- zip: '10001',
51
- },
52
- },
53
- } as any);
54
- });
55
-
56
- test('formDataToJSON handles FormData with array of values', () => {
57
- const formData = new FormData();
58
- formData.append('tags', 'javascript');
59
- formData.append('tags', 'typescript');
60
- formData.append('tags', 'nodejs');
61
- formData.append('colors[]', 'red');
62
- formData.append('colors[]', 'green');
63
- formData.append('colors[]', 'blue');
64
-
65
- const result = formDataToJSON(formData);
66
-
67
- expect(result).toEqual({
68
- tags: ['javascript', 'typescript', 'nodejs'],
69
- colors: ['red', 'green', 'blue'],
70
- });
71
- });
72
- });
73
-
74
8
  describe('createAction', () => {
75
9
  test('creates an action with a simple form and no schema', async () => {
76
10
  const action = createAction({
77
- form: (c: HS.Context) => {
78
- return html`
11
+ name: 'test',
12
+ schema: z.object({
13
+ name: z.string().min(1, 'Name is required'),
14
+ }),
15
+ }).form((c) => {
16
+ return html`
79
17
  <form>
80
18
  <input type="text" name="name" />
81
19
  <button type="submit">Submit</button>
82
20
  </form>
83
21
  `;
84
- },
22
+ }).post(async (c, { data }) => {
23
+ return c.res.html(`
24
+ <p>Hello, ${data?.name}!</p>
25
+ `);
85
26
  });
86
27
 
87
28
  expect(action).toBeDefined();
88
29
  expect(action._kind).toBe('hsAction');
89
- expect(action._route).toContain('/__actions/');
30
+ expect(action._path()).toContain('/__actions/');
90
31
 
91
32
  // Test render method
92
33
  const request = new Request('http://localhost:3000/');
@@ -106,17 +47,17 @@ describe('createAction', () => {
106
47
  });
107
48
 
108
49
  const action = createAction({
50
+ name: 'test',
109
51
  schema,
110
- form: (c: HS.Context, { data }) => {
111
- return html`
52
+ }).form((c, { data }) => {
53
+ return html`
112
54
  <form>
113
- <input type="text" name="name" value="${data?.name || ''}" />
114
- <input type="email" name="email" value="${data?.email || ''}" />
55
+ <input type="text" name="name" />
56
+ <input type="email" name="email" />
115
57
  <button type="submit">Submit</button>
116
58
  </form>
117
59
  `;
118
- },
119
- }).post(async (c: HS.Context, { data }) => {
60
+ }).post(async (c, { data }) => {
120
61
  return c.res.html(`
121
62
  <p>Hello, ${data?.name}!</p>
122
63
  <p>Your email is ${data?.email}.</p>
@@ -125,7 +66,7 @@ describe('createAction', () => {
125
66
 
126
67
  expect(action).toBeDefined();
127
68
  expect(action._kind).toBe('hsAction');
128
- expect(action._route).toContain('/__actions/');
69
+ expect(action._path()).toContain('/__actions/');
129
70
 
130
71
  // Test render method
131
72
  const request = new Request('http://localhost:3000/');
@@ -142,7 +83,7 @@ describe('createAction', () => {
142
83
  formData.append('name', 'John Doe');
143
84
  formData.append('email', 'john@example.com');
144
85
 
145
- const postRequest = new Request(`http://localhost:3000${action._route}`, {
86
+ const postRequest = new Request(`http://localhost:3000${action._path()}`, {
146
87
  method: 'POST',
147
88
  body: formData,
148
89
  });
@@ -163,9 +104,10 @@ describe('createAction', () => {
163
104
  });
164
105
 
165
106
  const action = createAction({
107
+ name: 'test',
166
108
  schema,
167
- form: (c: HS.Context, { data, error }) => {
168
- return html`
109
+ }).form((c, { data, error }) => {
110
+ return html`
169
111
  <form>
170
112
  <input type="text" name="name" value="${data?.name || ''}" />
171
113
  ${error ? html`<div class="error">Validation failed</div>` : ''}
@@ -173,8 +115,7 @@ describe('createAction', () => {
173
115
  <button type="submit">Submit</button>
174
116
  </form>
175
117
  `;
176
- },
177
- }).post(async (c: HS.Context, { data }) => {
118
+ }).post(async (c, { data }) => {
178
119
  return c.res.html(`
179
120
  <p>Hello, ${data?.name}!</p>
180
121
  <p>Your email is ${data?.email}.</p>
package/src/cookies.ts ADDED
@@ -0,0 +1,234 @@
1
+ import type { Hyperspan as HS } from './types';
2
+
3
+ const REGEXP_PAIR_SPLIT = /; */;
4
+
5
+ /**
6
+ * RegExp to match field-content in RFC 7230 sec 3.2
7
+ *
8
+ * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]
9
+ * field-vchar = VCHAR / obs-text
10
+ * obs-text = %x80-FF
11
+ */
12
+
13
+ const REGEXP_FIELD_CONTENT = /^[\u0009\u0020-\u007e\u0080-\u00ff]+$/;
14
+
15
+ /**
16
+ * Cookie parsing and serialization
17
+ */
18
+ export class Cookies implements HS.Cookies {
19
+ _req: Request;
20
+ _responseHeaders: HS.Cookies['_responseHeaders'];
21
+ _parsedCookies: HS.Cookies['_parsedCookies'] = {};
22
+ _encrypt: HS.Cookies['_encrypt'];
23
+ _decrypt: HS.Cookies['_decrypt'];
24
+ constructor(req: Request, responseHeaders: HS.Cookies['_responseHeaders'] = undefined) {
25
+ this._req = req;
26
+ this._responseHeaders = responseHeaders;
27
+ this._parsedCookies = parse(req.headers.get('Cookie') || '');
28
+ }
29
+
30
+ get(name: string): string | undefined {
31
+ const value = this._parsedCookies[name];
32
+ if (value && this._decrypt) {
33
+ return this._decrypt(value);
34
+ }
35
+ return value;
36
+ }
37
+
38
+ set(name: string, value: string, options?: HS.CookieOptions) {
39
+ if (!this._responseHeaders) {
40
+ throw new Error('Set cookies in the response object. Cookies can only be read from the request object.');
41
+ }
42
+ if (this._encrypt) {
43
+ value = this._encrypt(value);
44
+ }
45
+ this._responseHeaders.set('Set-Cookie', serialize(name, value, options));
46
+ }
47
+
48
+ delete(name: string) {
49
+ this.set(name, '', { expires: new Date(0) });
50
+ }
51
+
52
+ /**
53
+ * Set the encoder and decoder functions for the cookies and re-parse the cookie header
54
+ */
55
+ setEncryption(encrypt: HS.Cookies['_encrypt'], decrypt: HS.Cookies['_decrypt']) {
56
+ this._encrypt = encrypt;
57
+ this._decrypt = decrypt;
58
+ this._parsedCookies = parse(this._req.headers.get('Cookie') || '');
59
+ }
60
+ }
61
+
62
+ /*!
63
+ * cookie
64
+ * @source https://github.com/jkohrman/cookie-parse/blob/master/index.js
65
+ * Copyright(c) 2012-2014 Roman Shtylman
66
+ * Copyright(c) 2015 Douglas Christopher Wilson
67
+ * Copyright(c) 2016 Jeff Kohrman
68
+ * MIT Licensed
69
+ */
70
+
71
+ /**
72
+ * Parse a cookie header.
73
+ *
74
+ * Parse the given cookie header string into an object
75
+ * The object has the various cookies as keys(names) => values
76
+ *
77
+ * @param {string} str
78
+ * @param {object} [options]
79
+ * @return {object}
80
+ * @public
81
+ */
82
+
83
+ function parse(str: string): Record<string, string | any | undefined> {
84
+ if (typeof str !== 'string') {
85
+ throw new TypeError('argument str must be a string');
86
+ }
87
+
88
+ const obj = {}
89
+ const pairs = str.split(REGEXP_PAIR_SPLIT);
90
+
91
+ for (let i = 0; i < pairs.length; i++) {
92
+ const pair = pairs[i];
93
+ let eq_idx = pair.indexOf('=');
94
+
95
+ // set true for things that don't look like key=value
96
+ let key;
97
+ let val;
98
+ if (eq_idx < 0) {
99
+ key = pair.trim();
100
+ val = 'true';
101
+ } else {
102
+ key = pair.substring(0, eq_idx).trim()
103
+ val = pair.substring(eq_idx + 1, eq_idx + 1 + pair.length).trim();
104
+ };
105
+
106
+ // quoted values
107
+ if ('"' == val[0]) {
108
+ val = val.slice(1, -1);
109
+ }
110
+
111
+ // only assign once
112
+ // @ts-ignore
113
+ if (undefined == obj[key]) {
114
+ // @ts-ignore
115
+ obj[key] = tryDecode(val, decodeURIComponent);
116
+ }
117
+ }
118
+
119
+ return obj;
120
+ }
121
+
122
+ /**
123
+ * Serialize data into a cookie header.
124
+ *
125
+ * Serialize the a name value pair into a cookie string suitable for
126
+ * http headers. An optional options object specified cookie parameters.
127
+ *
128
+ * serialize('foo', 'bar', { httpOnly: true })
129
+ * => "foo=bar; httpOnly"
130
+ *
131
+ * @param {string} name
132
+ * @param {string} val
133
+ * @param {object} [options]
134
+ * @return {string}
135
+ * @public
136
+ */
137
+ type SerializeOptions = {
138
+ encrypt?: (str: string) => string;
139
+ maxAge?: number;
140
+ domain?: string;
141
+ path?: string;
142
+ expires?: Date;
143
+ httpOnly?: boolean;
144
+ secure?: boolean;
145
+ sameSite?: 'lax' | 'strict' | true;
146
+ };
147
+ function serialize(name: string, val: string, options: SerializeOptions = {}) {
148
+ const opt = options || {};
149
+
150
+ if (!REGEXP_FIELD_CONTENT.test(name)) {
151
+ throw new TypeError('argument name is invalid');
152
+ }
153
+
154
+ let value = encodeURIComponent(val);
155
+
156
+ if (value && !REGEXP_FIELD_CONTENT.test(value)) {
157
+ throw new TypeError('argument val is invalid');
158
+ }
159
+
160
+ let str = name + '=' + value;
161
+
162
+ if (null != opt.maxAge) {
163
+ const maxAge = opt.maxAge - 0;
164
+ if (isNaN(maxAge)) throw new Error('maxAge should be a Number');
165
+ str += '; Max-Age=' + Math.floor(maxAge);
166
+ }
167
+
168
+ if (opt.domain) {
169
+ if (!REGEXP_FIELD_CONTENT.test(opt.domain)) {
170
+ throw new TypeError('option domain is invalid');
171
+ }
172
+
173
+ str += '; Domain=' + opt.domain;
174
+ }
175
+
176
+ if (opt.path) {
177
+ if (!REGEXP_FIELD_CONTENT.test(opt.path)) {
178
+ throw new TypeError('option path is invalid');
179
+ }
180
+
181
+ str += '; Path=' + opt.path;
182
+ }
183
+
184
+ if (opt.expires) {
185
+ if (typeof opt.expires.toUTCString !== 'function') {
186
+ throw new TypeError('option expires must be a Date object');
187
+ }
188
+
189
+ str += '; Expires=' + opt.expires.toUTCString();
190
+ }
191
+
192
+ if (opt.httpOnly) {
193
+ str += '; HttpOnly';
194
+ }
195
+
196
+ if (opt.secure) {
197
+ str += '; Secure';
198
+ }
199
+
200
+ if (opt.sameSite) {
201
+ const sameSite = typeof opt.sameSite === 'string'
202
+ ? opt.sameSite.toLowerCase() : opt.sameSite;
203
+
204
+ switch (sameSite) {
205
+ case true:
206
+ case 'strict':
207
+ str += '; SameSite=Strict';
208
+ break;
209
+ case 'lax':
210
+ str += '; SameSite=Lax';
211
+ break;
212
+ default:
213
+ throw new TypeError('option sameSite is invalid');
214
+ }
215
+ }
216
+
217
+ return str;
218
+ }
219
+
220
+ /**
221
+ * Try decoding a string using a decoding function.
222
+ *
223
+ * @param {string} str
224
+ * @param {function} decode
225
+ * @private
226
+ */
227
+
228
+ function tryDecode(str: string, decode: (str: string) => string) {
229
+ try {
230
+ return decode(str);
231
+ } catch (e) {
232
+ return str;
233
+ }
234
+ }
package/src/index.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { createConfig, createContext, createRoute, createServer, getRunnableRoute, StreamResponse, IS_PROD } from './server';
1
+ export { createConfig, createContext, createRoute, createServer, getRunnableRoute, StreamResponse, IS_PROD, HTTPException } from './server';
2
2
  export type { Hyperspan } from './types';
@@ -1,5 +1,5 @@
1
1
  import { test, expect } from 'bun:test';
2
- import { createRoute, createServer } from './server';
2
+ import { createRoute, createServer, createContext } from './server';
3
3
  import type { Hyperspan as HS } from './types';
4
4
 
5
5
  test('route fetch() returns a Response', async () => {
@@ -89,3 +89,124 @@ test('returns 405 when route path matches but HTTP method does not', async () =>
89
89
  const text = await response.text();
90
90
  expect(text).toContain('Method not allowed');
91
91
  });
92
+
93
+ test('createContext() can get and set cookies', () => {
94
+ // Create a request with cookies in the Cookie header
95
+ const request = new Request('http://localhost:3000/', {
96
+ headers: {
97
+ 'Cookie': 'sessionId=abc123; theme=dark; userId=42',
98
+ },
99
+ });
100
+
101
+ // Create context from the request
102
+ const context = createContext(request);
103
+
104
+ // Test reading cookies from request
105
+ expect(context.req.cookies.get('sessionId')).toBe('abc123');
106
+ expect(context.req.cookies.get('theme')).toBe('dark');
107
+ expect(context.req.cookies.get('userId')).toBe('42');
108
+ expect(context.req.cookies.get('nonExistent')).toBeUndefined();
109
+
110
+ // Test setting a simple cookie in response
111
+ context.res.cookies.set('newCookie', 'newValue');
112
+ let setCookieHeader = context.res.headers.get('Set-Cookie');
113
+ expect(setCookieHeader).toBeTruthy();
114
+ expect(setCookieHeader).toContain('newCookie=newValue');
115
+
116
+ // Test setting a cookie with options (this will overwrite the previous Set-Cookie header)
117
+ context.res.cookies.set('secureCookie', 'secureValue', {
118
+ httpOnly: true,
119
+ secure: true,
120
+ sameSite: 'strict',
121
+ maxAge: 3600,
122
+ });
123
+
124
+ // Verify Set-Cookie header contains the last cookie set
125
+ setCookieHeader = context.res.headers.get('Set-Cookie');
126
+ expect(setCookieHeader).toBeTruthy();
127
+ expect(setCookieHeader).toContain('secureCookie=secureValue');
128
+ expect(setCookieHeader).toContain('HttpOnly');
129
+ expect(setCookieHeader).toContain('Secure');
130
+ expect(setCookieHeader).toContain('SameSite=Strict');
131
+ expect(setCookieHeader).toContain('Max-Age=3600');
132
+
133
+ // Verify the previous cookie was overwritten
134
+ expect(setCookieHeader).not.toContain('newCookie=newValue');
135
+
136
+ // Test deleting a cookie
137
+ context.res.cookies.delete('sessionId');
138
+ setCookieHeader = context.res.headers.get('Set-Cookie');
139
+ expect(setCookieHeader).toBeTruthy();
140
+ if (setCookieHeader) {
141
+ expect(setCookieHeader).toContain('sessionId=');
142
+ expect(setCookieHeader).toContain('Expires=');
143
+ // Verify it's set to expire in the past (deleted)
144
+ const expiresMatch = setCookieHeader.match(/Expires=([^;]+)/);
145
+ expect(expiresMatch).toBeTruthy();
146
+ if (expiresMatch) {
147
+ const expiresDate = new Date(expiresMatch[1]);
148
+ expect(expiresDate.getTime()).toBeLessThanOrEqual(new Date(0).getTime());
149
+ }
150
+ }
151
+ });
152
+
153
+ test('createContext() merge() function preserves custom headers when using response methods', () => {
154
+ // Create a request
155
+ const request = new Request('http://localhost:3000/');
156
+
157
+ // Create context from the request
158
+ const context = createContext(request);
159
+
160
+ // Set custom headers on the context response
161
+ context.res.headers.set('X-Custom-Header', 'custom-value');
162
+ context.res.headers.set('X-Another-Header', 'another-value');
163
+ context.res.headers.set('Authorization', 'Bearer token123');
164
+
165
+ // Use html() method which should merge headers
166
+ const response = context.res.html('<h1>Test</h1>');
167
+
168
+ // Verify the response has both the custom headers and the Content-Type header
169
+ expect(response.headers.get('X-Custom-Header')).toBe('custom-value');
170
+ expect(response.headers.get('X-Another-Header')).toBe('another-value');
171
+ expect(response.headers.get('Authorization')).toBe('Bearer token123');
172
+ expect(response.headers.get('Content-Type')).toBe('text/html; charset=UTF-8');
173
+
174
+ // Verify response body is correct
175
+ expect(response.status).toBe(200);
176
+ });
177
+
178
+ test('createContext() merge() function preserves custom headers with json() method', () => {
179
+ const request = new Request('http://localhost:3000/');
180
+ const context = createContext(request);
181
+
182
+ // Set custom headers
183
+ context.res.headers.set('X-API-Version', 'v1');
184
+ context.res.headers.set('X-Request-ID', 'req-123');
185
+
186
+ // Use json() method
187
+ const response = context.res.json({ message: 'Hello' });
188
+
189
+ // Verify headers are merged
190
+ expect(response.headers.get('X-API-Version')).toBe('v1');
191
+ expect(response.headers.get('X-Request-ID')).toBe('req-123');
192
+ expect(response.headers.get('Content-Type')).toBe('application/json');
193
+ });
194
+
195
+ test('createContext() merge() function allows response headers to override context headers', () => {
196
+ const request = new Request('http://localhost:3000/');
197
+ const context = createContext(request);
198
+
199
+ // Set a header on context
200
+ context.res.headers.set('X-Header', 'context-value');
201
+
202
+ // Use html() with options that include the same header (should override)
203
+ const response = context.res.html('<h1>Test</h1>', {
204
+ headers: {
205
+ 'X-Header': 'response-value',
206
+ },
207
+ });
208
+
209
+ // Response header should override context header
210
+ expect(response.headers.get('X-Header')).toBe('response-value');
211
+ expect(response.headers.get('Content-Type')).toBe('text/html; charset=UTF-8');
212
+ });
package/src/server.ts CHANGED
@@ -1,9 +1,10 @@
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';
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
 
@@ -37,6 +38,19 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
37
38
  // @ts-ignore - Bun will put 'params' on the Request object even though it's not standardized
38
39
  const params: HS.RouteParamsParser<path> = req?.params || {};
39
40
 
41
+ const merge = (response: Response) => {
42
+ // Convert headers to plain objects and merge (response headers override context headers)
43
+ const mergedHeaders = {
44
+ ...Object.fromEntries(headers.entries()),
45
+ ...Object.fromEntries(response.headers.entries()),
46
+ };
47
+
48
+ return new Response(response.body, {
49
+ status: response.status,
50
+ headers: mergedHeaders,
51
+ });
52
+ };
53
+
40
54
  return {
41
55
  vars: {},
42
56
  route: {
@@ -50,20 +64,23 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
50
64
  method,
51
65
  headers,
52
66
  query,
67
+ cookies: new Cookies(req),
53
68
  async text() { return req.text() },
54
69
  async json<T = unknown>() { return await req.json() as T },
55
70
  async formData<T = unknown>() { return await req.formData() as T },
56
71
  async urlencoded() { return new URLSearchParams(await req.text()) },
57
72
  },
58
73
  res: {
59
- headers: new Headers(),
74
+ cookies: new Cookies(req, headers),
75
+ headers,
60
76
  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 }),
77
+ html: (html: string, options?: { status?: number; headers?: Headers | Record<string, string> }) => merge(new Response(html, { ...options, headers: { 'Content-Type': 'text/html; charset=UTF-8', ...options?.headers } })),
78
+ json: (json: any, options?: { status?: number; headers?: Headers | Record<string, string> }) => merge(new Response(JSON.stringify(json), { ...options, headers: { 'Content-Type': 'application/json', ...options?.headers } })),
79
+ text: (text: string, options?: { status?: number; headers?: Headers | Record<string, string> }) => merge(new Response(text, { ...options, headers: { 'Content-Type': 'text/plain; charset=UTF-8', ...options?.headers } })),
80
+ redirect: (url: string, options?: { status?: number; headers?: Headers | Record<string, string> }) => merge(new Response(null, { status: 302, headers: { Location: url, ...options?.headers } })),
81
+ error: (error: Error, options?: { status?: number; headers?: Headers | Record<string, string> }) => merge(new Response(error.message, { status: 500, ...options })),
82
+ notFound: (options?: { status?: number; headers?: Headers | Record<string, string> }) => merge(new Response('Not Found', { status: 404, ...options })),
83
+ merge,
67
84
  },
68
85
  };
69
86
  }
@@ -79,7 +96,6 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
79
96
 
80
97
  const api: HS.Route = {
81
98
  _kind: 'hsRoute',
82
- _name: config.name,
83
99
  _config: config,
84
100
  _methods: () => Object.keys(_handlers),
85
101
  _path() {
package/src/types.ts CHANGED
@@ -29,6 +29,26 @@ export namespace Hyperspan {
29
29
  afterRoutesAdded?: (server: Hyperspan.Server) => void;
30
30
  };
31
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
+
32
52
  export interface Context {
33
53
  vars: Record<string, any>;
34
54
  route: {
@@ -42,12 +62,14 @@ export namespace Hyperspan {
42
62
  method: string; // Always uppercase
43
63
  headers: Headers; // Case-insensitive
44
64
  query: URLSearchParams;
65
+ cookies: Hyperspan.Cookies;
45
66
  text: () => Promise<string>;
46
67
  json<T = unknown>(): Promise<T>;
47
- formData<T = unknown>(): Promise<T>;
68
+ formData(): Promise<FormData>;
48
69
  urlencoded(): Promise<URLSearchParams>;
49
70
  };
50
71
  res: {
72
+ cookies: Hyperspan.Cookies;
51
73
  headers: Headers; // Headers to merge with final outgoing response
52
74
  html: (html: string, options?: { status?: number; headers?: Record<string, string> }) => Response
53
75
  json: (json: any, options?: { status?: number; headers?: Record<string, string> }) => Response;
@@ -55,6 +77,7 @@ export namespace Hyperspan {
55
77
  redirect: (url: string, options?: { status?: number; headers?: Record<string, string> }) => Response;
56
78
  error: (error: Error, options?: { status?: number; headers?: Record<string, string> }) => Response;
57
79
  notFound: (options?: { status?: number; headers?: Record<string, string> }) => Response;
80
+ merge: (response: Response) => Response;
58
81
  raw: Response;
59
82
  };
60
83
  };
@@ -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
@@ -33,7 +33,7 @@ export function parsePath(urlPath: string): { path: string, params: string[] } {
33
33
  // Dynamic params
34
34
  if (ROUTE_SEGMENT_REGEX.test(urlPath)) {
35
35
  urlPath = urlPath.replace(ROUTE_SEGMENT_REGEX, (match: string) => {
36
- const paramName = match.replace(/[^a-zA-Z_\.]+/g, '');
36
+ const paramName = match.replace(/[^a-zA-Z_\.]+/g, '').replace('...', '');
37
37
  params.push(paramName);
38
38
 
39
39
  if (match.includes('...')) {