@trenskow/app 0.9.26 → 0.9.27

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/README.md CHANGED
@@ -248,6 +248,7 @@ When a request is incoming, the `context` object looks like this.
248
248
  | `path.current` | An array of strings that joined represents the path currently being processed. | Array of String |
249
249
  | `path.remaining` | An array of strings that joined represents the path that is above the currently processed path. Setting this will rewrite the remaining path (useful when serving single page applications to a browser). | Array of String |
250
250
  | `query` | An object holding the URL query parameters as an object ([keys has been converted to camel case](#query-parameters)). | Object |
251
+ | `cookies` | An mutable object with cookie values. | Object |
251
252
  | `state` | A string indicating the current state of the request – possible values are `'routing'`, `'rendering'`, `'completed'` or `'aborted'`. | String |
252
253
  | `abort` | A function that aborts the request. It takes the parameters `(error, brutally)`, where `error` is the error that needs to be handled by the [renderer](#renderer) – and `brutally` which indicates if the connection should also be closed. | AsyncFunction |
253
254
  | `render` | A function that tells the application to stop processing the request and jump directly to the [renderer](#renderer). | Function |
@@ -276,7 +277,11 @@ The same goes for request headers like `Accept-Language: en` which is accessible
276
277
 
277
278
  ##### Query parameters
278
279
 
279
- Request with quuries like `?my-parameter=value` is accessible through `context.query.myParameter` .
280
+ Request with queries like `?my-parameter=value` is accessible through `context.query.myParameter` .
281
+
282
+ ##### Cookies
283
+
284
+ Cookies are available through `context.cookies.myCookieKey` and are passed to the client using `camel` (can be changed using the [`Application` options](#constructor)).
280
285
 
281
286
  ##### Mount paths
282
287
 
@@ -356,11 +361,13 @@ The `Application` class takes an "options" object as it's parameter.
356
361
  | `options.port` | The port at which to listen for incoming connections. | Number | | `0` (automatically assigned) |
357
362
  | `options.RequestType` | An object that inherits from the [`Request`](#request-2) class (an `http.IncomingMessage` subclass) that is used as the request object in routes. | class | | [`Request`](#Request) |
358
363
  | `options.ResponseType` | An object that inherits from the [`Response`](#response-2) class (`http.ServerResponse` subclass) that is used as the response object in routes. | class | | [`Response`](#Response) |
359
- | `path` | An object that represents path related options. | Object | | `{}` |
360
- | `path.matchMode` | Indicates [how to match requests to mounted paths](#mount-paths) (eg. should the path be converted to camel case). | `'loosely'` or `'strict'` | | `'loosely'` |
364
+ | `options.path` | An object that represents path related options. | Object | | `{}` |
365
+ | `options.path.matchMode` | Indicates [how to match requests to mounted paths](#mount-paths) (eg. should the path be converted to camel case). | `'loosely'` or `'strict'` | | `'loosely'` |
361
366
  | `options.server` | An object that represents how to instantiate the HTTP server. | Object | | `{}` |
362
367
  | `options.server.create` | A function that is able to create a server. | Function | | `http.createServer` |
363
368
  | `options.server.options` | An object to be passed as options when creating a server. | Object | | `{}` |
369
+ | `options.casing` | An object that represents casing options. | Object | | {} |
370
+ | `options.casing.cookies` | Client side cookie key value casing (available as in [@trenskow/caseit](https://github.com/trenskow/caseit)). | String | | `camel` |
364
371
 
365
372
  #### Events
366
373
 
@@ -16,6 +16,7 @@ import caseit from '@trenskow/caseit';
16
16
  import Request from './request.js';
17
17
  import Response from './response.js';
18
18
  import Endpoint from './endpoint.js';
19
+ import Cookies from './cookies.js';
19
20
 
20
21
  import { isObject } from './util/index.js';
21
22
 
@@ -25,6 +26,7 @@ export default class Application extends EventEmitter {
25
26
  #_path;
26
27
  #_state;
27
28
  #_server;
29
+ #_casing;
28
30
  #_rootEndpoint;
29
31
  #_renderer;
30
32
 
@@ -41,7 +43,10 @@ export default class Application extends EventEmitter {
41
43
  server = Object.assign({
42
44
  create: http.createServer,
43
45
  options: {}
44
- }, options.server || {})
46
+ }, options.server || {}),
47
+ casing = Object.assign({
48
+ cookies: 'camel'
49
+ }, options.casing || {})
45
50
  } = options;
46
51
 
47
52
  this.#_port = port;
@@ -62,6 +67,8 @@ export default class Application extends EventEmitter {
62
67
  this.#_onIncomingRequest(req, res);
63
68
  });
64
69
 
70
+ this.#_casing = casing;
71
+
65
72
  this.#_rootEndpoint = new Endpoint()
66
73
  .use(() => { throw new ApiError.NotFound(); });
67
74
 
@@ -314,6 +321,13 @@ export default class Application extends EventEmitter {
314
321
  enumerable: true
315
322
  });
316
323
 
324
+ const cookies = new Cookies(context, this.#_casing.cookies);
325
+
326
+ Object.defineProperty(context, 'cookies', {
327
+ get: () => cookies._proxy,
328
+ enumerable: true
329
+ });
330
+
317
331
  const listeners = {
318
332
  eventEmitters: [
319
333
  [request.socket, ['end', 'error']],
@@ -370,6 +384,8 @@ export default class Application extends EventEmitter {
370
384
 
371
385
  }
372
386
 
387
+ cookies._render();
388
+
373
389
  if (!['routing', 'rendering'].includes(state)) return response.end();
374
390
 
375
391
  await render();
package/lib/cookies.js ADDED
@@ -0,0 +1,179 @@
1
+ //
2
+ // application.js
3
+ // @trenskow/app
4
+ //
5
+ // Created by Kristian Trenskow on 2021/11/07
6
+ // For license see LICENSE.
7
+ //
8
+
9
+ import caseit from '@trenskow/caseit';
10
+ import { duration } from '@trenskow/units';
11
+
12
+ export default class Cookies {
13
+
14
+ #_cookies = {
15
+ current: {},
16
+ requested: {},
17
+ };
18
+
19
+ #_request;
20
+ #_response;
21
+ #_path;
22
+ #_casing;
23
+ #_proxy;
24
+
25
+ constructor({ request, response, path }, casing = this.#_casing) {
26
+
27
+ if (!request || !response) {
28
+ throw new Error('Cookies must be initialized with a request and response.');
29
+ }
30
+
31
+ this.#_request = request;
32
+ this.#_response = response;
33
+ this.#_path = path;
34
+ this.#_casing = casing;
35
+
36
+ (this.#_request.headers?.cookie || '').split(/; ?/)
37
+ .filter(cookie => cookie)
38
+ .forEach(cookie => {
39
+
40
+ const [name, value] = cookie.split('=')
41
+ .map(part => decodeURIComponent(part.trim()));
42
+
43
+ this.#_cookies.requested[caseit(name)] = (this.#_cookies.current[caseit(name)] = { value }).value;
44
+
45
+ });
46
+
47
+ }
48
+
49
+ get _proxy() {
50
+ return this.#_proxy || (this.#_proxy = new Proxy(this, {
51
+ get: (target, prop) => {
52
+
53
+ prop = caseit(prop);
54
+
55
+ if (Object.hasOwn(target, prop) || Object.hasOwn(Object.getPrototypeOf(target), prop)) {
56
+ return target[prop];
57
+ }
58
+
59
+ return target.#_cookies.current[prop]?.value;
60
+
61
+ },
62
+ set: (target, prop, value) => {
63
+
64
+ prop = caseit(prop);
65
+
66
+ if (typeof value === 'undefined') {
67
+ delete target.#_cookies.current[prop];
68
+ } else if (typeof value === 'undefined' || typeof value === 'string') {
69
+ this.#_proxy[prop] = { value };
70
+ } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
71
+
72
+ let {
73
+ value: cookieValue,
74
+ expires,
75
+ path = 'current',
76
+ domain = 'current',
77
+ secure = true,
78
+ httpOnly = false
79
+ } = value;
80
+
81
+ if (typeof cookieValue === 'undefined') {
82
+ this.#_proxy[prop] = undefined;
83
+ return true;
84
+ } else if (typeof cookieValue !== 'string') {
85
+ throw new TypeError('Cookie values must be a string.');
86
+ }
87
+
88
+ if (path === 'current') {
89
+ path = `${this.#_path.current.join('/')}/`;
90
+ } else if (path === 'root') {
91
+ path = '/';
92
+ }
93
+
94
+ if (domain === 'current') {
95
+ domain = target.#_request.host;
96
+ } else if (domain === 'root') {
97
+ domain = target.#_request.host.split('.').slice(-2).join('.');
98
+ }
99
+
100
+ if (typeof expires !== 'undefined' && !(['number', 'string'].includes(typeof expires) || expires instanceof Date)) {
101
+ throw new TypeError('Expires must be a number, string or Date.');
102
+ }
103
+
104
+ target.#_cookies.current[prop] = {
105
+ value: cookieValue,
106
+ expires,
107
+ path,
108
+ domain,
109
+ secure,
110
+ httpOnly
111
+ };
112
+
113
+ } else {
114
+ throw new TypeError('Cookie values must be a strings, object or undefined.');
115
+ }
116
+
117
+ return true;
118
+
119
+ },
120
+ deleteProperty: (_, prop) => {
121
+ this.#_proxy[prop] = undefined;
122
+ return true;
123
+ },
124
+ }));
125
+ }
126
+
127
+ #_format(name, { value, expires, path, domain, secure, httpOnly }) {
128
+
129
+ const parts = [`${encodeURIComponent(caseit(name, this.#_casing))}=${encodeURIComponent(value || '')}`];
130
+
131
+ if (!value) {
132
+ parts.push('Max-Age=0');
133
+ } else if (expires) {
134
+ if (typeof expires === 'number' || typeof expires === 'string') {
135
+ parts.push(`Max-Age=${duration.s(expires)}`);
136
+ } else if (expires instanceof Date) {
137
+ parts.push(`Expires=${expires.toUTCString()}`);
138
+ }
139
+ }
140
+
141
+ if (path) {
142
+ parts.push(`Path=${path}`);
143
+ }
144
+
145
+ if (domain) {
146
+ parts.push(`Domain=${domain}`);
147
+ }
148
+
149
+ if (secure) {
150
+ parts.push('Secure');
151
+ }
152
+
153
+ if (httpOnly) {
154
+ parts.push('HttpOnly');
155
+ }
156
+
157
+ return parts.join('; ');
158
+
159
+ }
160
+
161
+ _render() {
162
+
163
+ const deletedCookies = Object.keys(this.#_cookies.requested)
164
+ .filter(name => !Object.hasOwn(this.#_cookies.current, name))
165
+ .map(name => this.#_format(name, { value: undefined }));
166
+
167
+ const allCookies = Object.entries(this.#_cookies.current)
168
+ .map(([name, cookie]) => this.#_format(name, cookie))
169
+ .concat(deletedCookies);
170
+
171
+ if (allCookies.length === 0) {
172
+ return;
173
+ }
174
+
175
+ this.#_response.headers.setCookie = allCookies;
176
+
177
+ }
178
+
179
+ };
package/lib/request.js CHANGED
@@ -28,6 +28,18 @@ export default class Request extends IncomingMessage {
28
28
 
29
29
  }
30
30
 
31
+ get host() {
32
+ return this.headers.xForwardedHost || super.host || this.socket.localAddress;
33
+ }
34
+
35
+ get protocol() {
36
+ return this.headers.xForwardedProto || super.protocol || (this.socket.encrypted ? 'https' : 'http');
37
+ }
38
+
39
+ get port() {
40
+ return this.headers.xForwardedPort || this.socket.localPort || (super.protocol === 'https' ? 443 : 80);
41
+ }
42
+
31
43
  get origin() {
32
44
  return (this.headers.xForwardedFor || this.socket.remoteAddress || '').split(/, ?/);
33
45
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trenskow/app",
3
- "version": "0.9.26",
3
+ "version": "0.9.27",
4
4
  "description": "A small HTTP router.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -35,6 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@trenskow/api-error": "^2.5.29",
38
- "@trenskow/caseit": "^1.4.12"
38
+ "@trenskow/caseit": "^1.4.12",
39
+ "@trenskow/units": "^0.2.27"
39
40
  }
40
41
  }
package/test/index.js CHANGED
@@ -439,6 +439,83 @@ describe('Application', () => {
439
439
 
440
440
  });
441
441
 
442
+ it ('should come back with header to set cookie.', async () => {
443
+
444
+ app.root(
445
+ new Endpoint()
446
+ .get(({ cookies }) => {
447
+ cookies['testValue'] = 'Hello, World!';
448
+ return 'Hello, World!';
449
+ })
450
+ );
451
+
452
+ await request
453
+ .get('/')
454
+ .expect('Set-Cookie', 'testValue=Hello%2C%20World!; Path=/; Domain=::1; Secure')
455
+ .expect(200, 'Hello, World!');
456
+
457
+ });
458
+
459
+ it ('should come back with an updated header to set cookie.', async () => {
460
+
461
+ app.root(
462
+ new Endpoint()
463
+ .get(({ cookies }) => {
464
+ cookies['testValue'] = {
465
+ value: 'Hello, World!',
466
+ path: '/test',
467
+ expires: '30d'
468
+ };
469
+ return 'Hello, World!';
470
+ })
471
+ );
472
+
473
+ await request
474
+ .get('/')
475
+ .set('Cookie', 'testValue=Hello')
476
+ .expect('Set-Cookie', 'testValue=Hello%2C%20World!; Max-Age=2592000; Path=/test; Domain=::1; Secure')
477
+ .expect(200, 'Hello, World!');
478
+
479
+ });
480
+
481
+ it ('should come back with an updated header to a deleted cookie.', async () => {
482
+
483
+ app.root(
484
+ new Endpoint()
485
+ .get(({ cookies }) => {
486
+ delete cookies['testValue'];
487
+ return 'Hello, World!';
488
+ })
489
+ );
490
+
491
+ await request
492
+ .get('/')
493
+ .set('Cookie', 'testValue=Hello')
494
+ .expect('Set-Cookie', 'testValue=; Max-Age=0')
495
+ .expect(200, 'Hello, World!');
496
+
497
+ });
498
+
499
+ it ('should come back with no new cookies if no cookies are set.', (done) => {
500
+
501
+ app.root(
502
+ new Endpoint()
503
+ .get(() => 'Hello, World!')
504
+ );
505
+
506
+ request
507
+ .get('/')
508
+ .expect(200, 'Hello, World!')
509
+ .end((error, response) => {
510
+ if (error) return done(error);
511
+ if (response.headers['set-cookie']) {
512
+ return done(new Error('Response should not have set-cookie header.'));
513
+ }
514
+ done();
515
+ });
516
+
517
+ });
518
+
442
519
  after(async () => {
443
520
  await app.close({ awaitAllConnections: true });
444
521
  });