@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/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.append('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 ADDED
@@ -0,0 +1,2 @@
1
+ export { createConfig, createContext, createRoute, createServer, getRunnableRoute, StreamResponse, IS_PROD, HTTPResponseException } from './server';
2
+ export type { Hyperspan } from './types';
package/src/plugins.ts CHANGED
@@ -46,6 +46,7 @@ export function clientJSPlugin(): HS.Plugin {
46
46
  const esmName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', '');
47
47
  JS_IMPORT_MAP.set(esmName, `${JS_PUBLIC_PATH}/${esmName}.js`);
48
48
 
49
+ // Get the contents of the file to extract the exports
49
50
  const contents = await result.outputs[0].text();
50
51
  const exportLine = EXPORT_REGEX.exec(contents);
51
52
 
@@ -65,10 +66,7 @@ export function clientJSPlugin(): HS.Plugin {
65
66
 
66
67
  // Export a special object that can be used to render the client JS as a script tag
67
68
  const moduleCode = `// hyperspan:processed
68
- import { functionToString } from '@hyperspan/framework/assets';
69
-
70
- // Original file contents
71
- ${contents}
69
+ import { functionToString } from '@hyperspan/framework/client/js';
72
70
 
73
71
  // hyperspan:client-js-plugin
74
72
  export const __CLIENT_JS = {
@@ -1,9 +1,9 @@
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 () => {
6
- const route = createRoute().get((context) => {
6
+ const route = createRoute().get((context: HS.Context) => {
7
7
  return context.res.html('<h1>Hello World</h1>');
8
8
  });
9
9
 
@@ -16,24 +16,25 @@ test('route fetch() returns a Response', async () => {
16
16
  });
17
17
 
18
18
  test('server with two routes can return Response from one', async () => {
19
- const server = createServer({
19
+ const server = await createServer({
20
20
  appDir: './app',
21
- staticFileRoot: './public',
21
+ publicDir: './public',
22
+ plugins: [],
22
23
  });
23
24
 
24
25
  // Add two routes to the server
25
- server.get('/users', (context) => {
26
+ server.get('/users', (context: HS.Context) => {
26
27
  return context.res.html('<h1>Users Page</h1>');
27
28
  });
28
29
 
29
- server.get('/posts', (context) => {
30
+ server.get('/posts', (context: HS.Context) => {
30
31
  return context.res.html('<h1>Posts Page</h1>');
31
32
  });
32
33
 
33
34
 
34
35
  // Test that we can get a Response from one of the routes
35
36
  const request = new Request('http://localhost:3000/users');
36
- const testRoute = server._routes.find((route) => route._path === '/users');
37
+ const testRoute = server._routes.find((route: HS.Route) => route._path() === '/users');
37
38
  const response = await testRoute!.fetch(request);
38
39
 
39
40
  expect(response).toBeInstanceOf(Response);
@@ -42,22 +43,23 @@ test('server with two routes can return Response from one', async () => {
42
43
  });
43
44
 
44
45
  test('server returns a route with a POST request', async () => {
45
- const server = createServer({
46
+ const server = await createServer({
46
47
  appDir: './app',
47
- staticFileRoot: './public',
48
+ publicDir: './public',
49
+ plugins: [],
48
50
  });
49
51
 
50
52
  // Add two routes to the server
51
- server.get('/users', (context) => {
53
+ server.get('/users', (context: HS.Context) => {
52
54
  return context.res.html('<h1>GET /users</h1>');
53
55
  });
54
56
 
55
- server.post('/users', (context) => {
57
+ server.post('/users', (context: HS.Context) => {
56
58
  return context.res.html('<h1>POST /users</h1>');
57
59
  });
58
60
 
59
- const route = server._routes.find((route) => route._path === '/users' && route._methods().includes('POST')) as HS.Route;
60
- const request = new Request('http://localhost:3000/', { method: 'POST' });
61
+ const route = server._routes.find((route: HS.Route) => route._path() === '/users' && route._methods().includes('POST')) as HS.Route;
62
+ const request = new Request('http://localhost:3000/users', { method: 'POST' });
61
63
  const response = await route.fetch(request);
62
64
 
63
65
  expect(response).toBeInstanceOf(Response);
@@ -66,18 +68,19 @@ test('server returns a route with a POST request', async () => {
66
68
  });
67
69
 
68
70
  test('returns 405 when route path matches but HTTP method does not', async () => {
69
- const server = createServer({
71
+ const server = await createServer({
70
72
  appDir: './app',
71
- staticFileRoot: './public',
73
+ publicDir: './public',
74
+ plugins: [],
72
75
  });
73
76
 
74
77
  // Route registered for GET only
75
- server.get('/users', (context) => {
78
+ server.get('/users', (context: HS.Context) => {
76
79
  return context.res.html('<h1>Users Page</h1>');
77
80
  });
78
81
 
79
82
  // Attempt to POST to /users, which should return 405
80
- const route = server._routes.find((route) => route._path === '/users')!;
83
+ const route = server._routes.find((route: HS.Route) => route._path() === '/users')!;
81
84
  const request = new Request('http://localhost:3000/users', { method: 'POST' });
82
85
  const response = await route.fetch(request);
83
86
 
@@ -86,3 +89,124 @@ test('returns 405 when route path matches but HTTP method does not', async () =>
86
89
  const text = await response.text();
87
90
  expect(text).toContain('Method not allowed');
88
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
+ });