@trenskow/app 0.9.26 → 0.9.28
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 +10 -3
- package/lib/application.js +17 -1
- package/lib/cookies.js +179 -0
- package/lib/request.js +12 -0
- package/package.json +6 -5
- package/test/index.js +77 -0
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
|
|
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`
|
|
360
|
-
| `path.matchMode`
|
|
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
|
|
package/lib/application.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.9.28",
|
|
4
4
|
"description": "A small HTTP router.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -28,13 +28,14 @@
|
|
|
28
28
|
"@eslint/eslintrc": "^3.3.1",
|
|
29
29
|
"@eslint/js": "^9.13.0",
|
|
30
30
|
"chai": "^5.2.0",
|
|
31
|
-
"eslint": "^9.
|
|
32
|
-
"globals": "^16.
|
|
33
|
-
"mocha": "^11.
|
|
31
|
+
"eslint": "^9.28.0",
|
|
32
|
+
"globals": "^16.2.0",
|
|
33
|
+
"mocha": "^11.5.0",
|
|
34
34
|
"supertest": "^7.1.1"
|
|
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
|
});
|