@salesforce/mrt-utilities 0.0.1 → 0.1.1
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 +49 -45
- package/dist/cjs/index.d.ts +4 -0
- package/dist/cjs/index.js +10 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/metrics/index.d.ts +1 -0
- package/dist/cjs/metrics/index.js +7 -0
- package/dist/cjs/metrics/index.js.map +1 -0
- package/dist/cjs/metrics/metrics-sender.d.ts +136 -0
- package/dist/cjs/metrics/metrics-sender.js +240 -0
- package/dist/cjs/metrics/metrics-sender.js.map +1 -0
- package/dist/cjs/middleware/data-store.d.ts +56 -0
- package/dist/cjs/middleware/data-store.js +124 -0
- package/dist/cjs/middleware/data-store.js.map +1 -0
- package/dist/cjs/middleware/index.d.ts +3 -0
- package/dist/cjs/middleware/index.js +8 -0
- package/dist/cjs/middleware/index.js.map +1 -0
- package/dist/cjs/middleware/middleware.d.ts +126 -0
- package/dist/cjs/middleware/middleware.js +416 -0
- package/dist/cjs/middleware/middleware.js.map +1 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/streaming/create-lambda-adapter.d.ts +68 -0
- package/dist/cjs/streaming/create-lambda-adapter.js +797 -0
- package/dist/cjs/streaming/create-lambda-adapter.js.map +1 -0
- package/dist/cjs/streaming/index.d.ts +1 -0
- package/dist/cjs/streaming/index.js +7 -0
- package/dist/cjs/streaming/index.js.map +1 -0
- package/dist/cjs/utils/configure-proxying.d.ts +160 -0
- package/dist/cjs/utils/configure-proxying.js +204 -0
- package/dist/cjs/utils/configure-proxying.js.map +1 -0
- package/dist/cjs/utils/ssr-proxying.d.ts +300 -0
- package/dist/cjs/utils/ssr-proxying.js +713 -0
- package/dist/cjs/utils/ssr-proxying.js.map +1 -0
- package/dist/cjs/utils/utils.d.ts +27 -0
- package/dist/cjs/utils/utils.js +44 -0
- package/dist/cjs/utils/utils.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +10 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/metrics/index.d.ts +1 -0
- package/dist/esm/metrics/index.js +7 -0
- package/dist/esm/metrics/index.js.map +1 -0
- package/dist/esm/metrics/metrics-sender.d.ts +136 -0
- package/dist/esm/metrics/metrics-sender.js +240 -0
- package/dist/esm/metrics/metrics-sender.js.map +1 -0
- package/dist/esm/middleware/data-store.d.ts +56 -0
- package/dist/esm/middleware/data-store.js +124 -0
- package/dist/esm/middleware/data-store.js.map +1 -0
- package/dist/esm/middleware/index.d.ts +3 -0
- package/dist/esm/middleware/index.js +9 -0
- package/dist/esm/middleware/index.js.map +1 -0
- package/dist/esm/middleware/middleware.d.ts +126 -0
- package/dist/esm/middleware/middleware.js +416 -0
- package/dist/esm/middleware/middleware.js.map +1 -0
- package/dist/esm/streaming/create-lambda-adapter.d.ts +68 -0
- package/dist/esm/streaming/create-lambda-adapter.js +797 -0
- package/dist/esm/streaming/create-lambda-adapter.js.map +1 -0
- package/dist/esm/streaming/index.d.ts +1 -0
- package/dist/esm/streaming/index.js +7 -0
- package/dist/esm/streaming/index.js.map +1 -0
- package/dist/esm/utils/configure-proxying.d.ts +160 -0
- package/dist/esm/utils/configure-proxying.js +204 -0
- package/dist/esm/utils/configure-proxying.js.map +1 -0
- package/dist/esm/utils/ssr-proxying.d.ts +300 -0
- package/dist/esm/utils/ssr-proxying.js +713 -0
- package/dist/esm/utils/ssr-proxying.js.map +1 -0
- package/dist/esm/utils/utils.d.ts +27 -0
- package/dist/esm/utils/utils.js +44 -0
- package/dist/esm/utils/utils.js.map +1 -0
- package/package.json +130 -7
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025, Salesforce, Inc.
|
|
3
|
+
* SPDX-License-Identifier: Apache-2
|
|
4
|
+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* @fileoverview SSR (Server-Side Rendering) Proxying utilities for MRT middleware.
|
|
8
|
+
*
|
|
9
|
+
* This module provides utilities for handling HTTP headers, cookies, and proxying
|
|
10
|
+
* in both Express.js applications and AWS Lambda@Edge functions. It's designed
|
|
11
|
+
* to work in multiple contexts while maintaining consistency.
|
|
12
|
+
*
|
|
13
|
+
* Special requirements:
|
|
14
|
+
* - Don't add any functionality in here that is not required by the proxying code
|
|
15
|
+
* - Avoid importing any other modules not explicitly used by this code
|
|
16
|
+
* - Must work in both Express.js and Lambda@Edge environments
|
|
17
|
+
*
|
|
18
|
+
* @author Salesforce Commerce Cloud
|
|
19
|
+
* @version 0.0.1
|
|
20
|
+
*/
|
|
21
|
+
/*
|
|
22
|
+
There are some special requirements for this module, which is used in the
|
|
23
|
+
SDK and also in Lambda@Edge functions run by CloudFront. Specifically:
|
|
24
|
+
- Don't add any functionality in here that is not required by the
|
|
25
|
+
proxying code.
|
|
26
|
+
- Avoid importing any other modules not explicitly used by this code
|
|
27
|
+
*/
|
|
28
|
+
import { parse as parseSetCookie } from 'set-cookie-parser';
|
|
29
|
+
import { trainCase } from 'change-case';
|
|
30
|
+
import { URL } from 'url';
|
|
31
|
+
const AC_ALLOW_ORIGIN = 'access-control-allow-origin';
|
|
32
|
+
const HOST = 'host';
|
|
33
|
+
const LOCATION = 'location';
|
|
34
|
+
const ORIGIN = 'origin';
|
|
35
|
+
const SET_COOKIE = 'set-cookie';
|
|
36
|
+
const USER_AGENT = 'user-agent';
|
|
37
|
+
const HEADER_FORMATS = ['http', 'aws'];
|
|
38
|
+
export const X_PROXY_REQUEST_URL = 'x-proxy-request-url';
|
|
39
|
+
export const X_MOBIFY_REQUEST_CLASS = 'x-mobify-request-class';
|
|
40
|
+
export const MAX_URL_LENGTH_BYTES = 8192;
|
|
41
|
+
/**
|
|
42
|
+
* This class provides a representation of HTTP request or response
|
|
43
|
+
* headers, that operates in the same way in multiple contexts
|
|
44
|
+
* (i.e. within the Express app as well as the request-processor).
|
|
45
|
+
*
|
|
46
|
+
* Within a Headers instance, headers are referenced using lower-case
|
|
47
|
+
* names. Use getHeader to access the value for a header. If there
|
|
48
|
+
* are multiple values, this will return the first value. This class
|
|
49
|
+
* internally supports round-trip preservation of multi-value headers,
|
|
50
|
+
* but does not yet provide a way to access them.
|
|
51
|
+
*/
|
|
52
|
+
export class Headers {
|
|
53
|
+
httpFormat;
|
|
54
|
+
headers;
|
|
55
|
+
_modified;
|
|
56
|
+
/*
|
|
57
|
+
A Lambda@Edge event contains headers in this form:
|
|
58
|
+
"headers": {
|
|
59
|
+
"host": [
|
|
60
|
+
{
|
|
61
|
+
"key": "Host",
|
|
62
|
+
"value": "d111111abcdef8.cloudfront.net"
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
"user-agent": [
|
|
66
|
+
{
|
|
67
|
+
"key": "User-Agent",
|
|
68
|
+
"value": "curl/7.18.1"
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
The http.IncomingMessage format is a simple object:
|
|
74
|
+
{
|
|
75
|
+
'user-agent': 'curl/7.22.0',
|
|
76
|
+
host: '127.0.0.1:8000'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
However, for IncomingMessage:
|
|
80
|
+
Duplicates of age, authorization, content-length, content-type, etag,
|
|
81
|
+
expires, from, host, if-modified-since, if-unmodified-since,
|
|
82
|
+
last-modified, location, max-forwards, proxy-authorization, referer,
|
|
83
|
+
retry-after, or user-agent are discarded.
|
|
84
|
+
The value for set-cookie is always an array. Duplicates are added to the array.
|
|
85
|
+
For all other headers, the values are joined together with ','
|
|
86
|
+
|
|
87
|
+
*/
|
|
88
|
+
/**
|
|
89
|
+
* Construct a Headers object from either an AWS Lambda Event headers
|
|
90
|
+
* object, or an http.IncomingMessage headers object.
|
|
91
|
+
*
|
|
92
|
+
* Project code should never need to call this constructor.
|
|
93
|
+
*
|
|
94
|
+
* @private
|
|
95
|
+
* @param headers the input headers
|
|
96
|
+
* @param format either 'http' or 'aws'
|
|
97
|
+
*/
|
|
98
|
+
constructor(headers, format) {
|
|
99
|
+
if (!HEADER_FORMATS.includes(format)) {
|
|
100
|
+
throw new Error(`Headers format must be one of ${HEADER_FORMATS.join(', ')}`);
|
|
101
|
+
}
|
|
102
|
+
this.httpFormat = format === 'http';
|
|
103
|
+
// Within this class, headers are represented by an object that maps
|
|
104
|
+
// header names (lower-case) to arrays of values.
|
|
105
|
+
this.headers = {};
|
|
106
|
+
for (const [key, values] of Object.entries(headers)) {
|
|
107
|
+
// For http format, the value will be a comma-separated
|
|
108
|
+
// list of values (https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2)
|
|
109
|
+
// For AWS format, the value is an array of key/value objects.
|
|
110
|
+
if (this.httpFormat) {
|
|
111
|
+
// The Set-Cookie header is always passed to us as an array,
|
|
112
|
+
// so we do not split.
|
|
113
|
+
if (key === SET_COOKIE) {
|
|
114
|
+
this.headers[key] = values.slice();
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
this.headers[key] = values.split(/,\s*/).map((value) => value.trim());
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
this.headers[key] = values.map((value) => value.value.trim());
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
this._modified = false;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Return true if and only if any set or delete methods were called on
|
|
128
|
+
* this instance after construction. This does not actually test if
|
|
129
|
+
* the headers values have been changed, just whether any mutating
|
|
130
|
+
* methods have been called.
|
|
131
|
+
* @returns {Boolean}
|
|
132
|
+
*/
|
|
133
|
+
get modified() {
|
|
134
|
+
return this._modified;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Return an array of the header keys (all lower-case)
|
|
138
|
+
*/
|
|
139
|
+
keys() {
|
|
140
|
+
return Object.keys(this.headers);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Get the value of the set-cookie header(s), returning an array
|
|
144
|
+
* of strings. Always returns an array, even if it's empty.
|
|
145
|
+
*/
|
|
146
|
+
getSetCookie() {
|
|
147
|
+
return this.headers[SET_COOKIE] || [];
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Set the value of the set-cookie header(s)
|
|
151
|
+
* @param values Array of set-cookie header values
|
|
152
|
+
*/
|
|
153
|
+
setSetCookie(values) {
|
|
154
|
+
this._modified = true;
|
|
155
|
+
if (!(values && values.length)) {
|
|
156
|
+
delete this.headers[SET_COOKIE];
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Clone the array
|
|
160
|
+
this.headers[SET_COOKIE] = values.slice();
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Return the FIRST value of the header with the given key.
|
|
164
|
+
* This is for single-value headers only: Location, Access-Control-*, etc
|
|
165
|
+
* If the header is not present, returns undefined.
|
|
166
|
+
* @param key header name
|
|
167
|
+
*/
|
|
168
|
+
getHeader(key) {
|
|
169
|
+
const keyLC = key.toLowerCase();
|
|
170
|
+
const values = this.headers[keyLC];
|
|
171
|
+
if (!values) {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
return values[0];
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Set the value of the header with the given key. This is for single-
|
|
178
|
+
* value headers only (see getHeader). Setting the value removes ALL other
|
|
179
|
+
* values for the given key.
|
|
180
|
+
* @param key header name
|
|
181
|
+
* @param value header value
|
|
182
|
+
*/
|
|
183
|
+
setHeader(key, value) {
|
|
184
|
+
this._modified = true;
|
|
185
|
+
const keyLC = key.toLowerCase();
|
|
186
|
+
this.headers[keyLC] = [value];
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Remove any header with the given key
|
|
190
|
+
* @param key header name to remove
|
|
191
|
+
*/
|
|
192
|
+
deleteHeader(key) {
|
|
193
|
+
this._modified = true;
|
|
194
|
+
const keyLC = key.toLowerCase();
|
|
195
|
+
delete this.headers[keyLC];
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Return the headers in AWS (Lambda event) format.
|
|
199
|
+
*
|
|
200
|
+
* Project code should never need to use this method.
|
|
201
|
+
*/
|
|
202
|
+
toAWSFormat() {
|
|
203
|
+
const result = {};
|
|
204
|
+
for (const [key, values] of Object.entries(this.headers)) {
|
|
205
|
+
// Some customer servers return headers with unusual keys; for
|
|
206
|
+
// example, 'cached_response'. Underscores are technically legal
|
|
207
|
+
// in header keys, but are unexpected. The problem is that the
|
|
208
|
+
// header-case package maps underscore to '-' to get "legal"
|
|
209
|
+
// names, which breaks Lambda validation. So if the key
|
|
210
|
+
// contains an underscore, we use it as-is.
|
|
211
|
+
const finalKey = key.includes('_') ? key : trainCase(key);
|
|
212
|
+
result[key] = values.map((value) => ({
|
|
213
|
+
key: finalKey,
|
|
214
|
+
value,
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
return result;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Return the headers in Express (http.IncomingMessage) format.
|
|
221
|
+
*
|
|
222
|
+
* RFC2616 allows some flexibility in how multiple values are
|
|
223
|
+
* combined into a single header value. We separate with ', '
|
|
224
|
+
* rather than just ',' to maintain previous behaviour.
|
|
225
|
+
*
|
|
226
|
+
* Project code should never need to use this method.
|
|
227
|
+
*/
|
|
228
|
+
toHTTPFormat() {
|
|
229
|
+
const result = {};
|
|
230
|
+
for (const [key, values] of Object.entries(this.headers)) {
|
|
231
|
+
// The Set-Cookie header is always returned as an array
|
|
232
|
+
if (key === SET_COOKIE) {
|
|
233
|
+
result[key] = values.slice();
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
result[key] = values.join(', ');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return result;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Return the headers in the same format (aws or http) that was
|
|
243
|
+
* used to construct them.
|
|
244
|
+
*
|
|
245
|
+
* Project code should never need to use this method.
|
|
246
|
+
*/
|
|
247
|
+
toObject() {
|
|
248
|
+
return this.httpFormat ? this.toHTTPFormat() : this.toAWSFormat();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
252
|
+
const monthAbbr = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
253
|
+
// Return the given number as a 2-digit string with a leading zero
|
|
254
|
+
const leadingZero = (n) => {
|
|
255
|
+
const s = Number(n).toString();
|
|
256
|
+
return s.length > 1 ? s : `0${s}`;
|
|
257
|
+
};
|
|
258
|
+
/**
|
|
259
|
+
* Return the given date as an RFC1123-format string, suitable for
|
|
260
|
+
* use in a Set-Cookie or Date header. The result is always in UTC.
|
|
261
|
+
* @function
|
|
262
|
+
* @param date Date object
|
|
263
|
+
* @returns RFC1123 formatted date string
|
|
264
|
+
*/
|
|
265
|
+
export const rfc1123 = (date) => {
|
|
266
|
+
const time = [
|
|
267
|
+
leadingZero(date.getUTCHours()),
|
|
268
|
+
leadingZero(date.getUTCMinutes()),
|
|
269
|
+
leadingZero(date.getUTCSeconds()),
|
|
270
|
+
].join(':');
|
|
271
|
+
return [
|
|
272
|
+
`${dayNames[date.getUTCDay()]},`,
|
|
273
|
+
leadingZero(date.getUTCDate()),
|
|
274
|
+
monthAbbr[date.getUTCMonth()],
|
|
275
|
+
date.getUTCFullYear(),
|
|
276
|
+
time,
|
|
277
|
+
'GMT',
|
|
278
|
+
].join(' ');
|
|
279
|
+
};
|
|
280
|
+
/**
|
|
281
|
+
* Given a cookie object parsed by set-cookie-parser,
|
|
282
|
+
* return a set-cookie header value for it.
|
|
283
|
+
* @private
|
|
284
|
+
*/
|
|
285
|
+
export const cookieAsString = (cookie) => {
|
|
286
|
+
const elements = [`${cookie.name}=${cookie.value}`];
|
|
287
|
+
if (cookie.path) {
|
|
288
|
+
elements.push(`Path=${cookie.path}`);
|
|
289
|
+
}
|
|
290
|
+
if (cookie.expires) {
|
|
291
|
+
// This is a Date object and must be represented as
|
|
292
|
+
// an HTTP-date timestamp (RFC1123 format). For example,
|
|
293
|
+
// Wed, 24-Oct-2018 00:13:20 GMT
|
|
294
|
+
elements.push(`Expires=${rfc1123(cookie.expires)}`);
|
|
295
|
+
}
|
|
296
|
+
if (cookie.domain) {
|
|
297
|
+
elements.push(`Domain=${cookie.domain}`);
|
|
298
|
+
}
|
|
299
|
+
if (cookie.maxAge) {
|
|
300
|
+
elements.push(`Max-Age=${cookie.maxAge}`);
|
|
301
|
+
}
|
|
302
|
+
if (cookie.secure) {
|
|
303
|
+
elements.push('Secure');
|
|
304
|
+
}
|
|
305
|
+
if (cookie.httpOnly) {
|
|
306
|
+
elements.push('HttpOnly');
|
|
307
|
+
}
|
|
308
|
+
if (cookie.sameSite) {
|
|
309
|
+
elements.push(`SameSite=${cookie.sameSite}`);
|
|
310
|
+
}
|
|
311
|
+
return elements.join('; ');
|
|
312
|
+
};
|
|
313
|
+
const IPV6 = 'IPV6';
|
|
314
|
+
const IPV4 = 'IPV4';
|
|
315
|
+
const LOCALHOST = 'localhost';
|
|
316
|
+
/**
|
|
317
|
+
* An array of {type, regexp, isIPOrLocalhost} objects, where the type is used
|
|
318
|
+
* to determine how to get a port, regexp will spot an IPv4, IPv6 or hostname,
|
|
319
|
+
* and the isIPOrLocalhost flag is true for IP addresses or locahost, false for
|
|
320
|
+
* hostnames. The main reason for detecting IP address/localhost is to determine
|
|
321
|
+
* whether it can have subdomains.
|
|
322
|
+
* @private
|
|
323
|
+
*/
|
|
324
|
+
const HOST_AND_PORT_TESTS = [
|
|
325
|
+
// IPV6 address plus port
|
|
326
|
+
{
|
|
327
|
+
type: IPV6,
|
|
328
|
+
regexp: /^\[([a-fA-F0-9:]+)\]:(\d+)$/,
|
|
329
|
+
isIPOrLocalhost: true,
|
|
330
|
+
hasPort: true,
|
|
331
|
+
},
|
|
332
|
+
// IPV6 address without port
|
|
333
|
+
{
|
|
334
|
+
type: IPV6,
|
|
335
|
+
regexp: /^([a-fA-F0-9:]+)$/,
|
|
336
|
+
isIPOrLocalhost: true,
|
|
337
|
+
hasPort: false,
|
|
338
|
+
},
|
|
339
|
+
// IPV4 address plus port
|
|
340
|
+
{
|
|
341
|
+
type: IPV4,
|
|
342
|
+
regexp: /^(\d+\.\d+\.\d+\.\d+):(\d+)$/,
|
|
343
|
+
isIPOrLocalhost: true,
|
|
344
|
+
hasPort: true,
|
|
345
|
+
},
|
|
346
|
+
// IPV4 address plus without port
|
|
347
|
+
{
|
|
348
|
+
type: IPV4,
|
|
349
|
+
regexp: /^(\d+\.\d+\.\d+\.\d+)$/,
|
|
350
|
+
isIPOrLocalhost: true,
|
|
351
|
+
hasPort: false,
|
|
352
|
+
},
|
|
353
|
+
// localhost plus port
|
|
354
|
+
{
|
|
355
|
+
type: LOCALHOST,
|
|
356
|
+
regexp: /^(localhost):(\d+)$/,
|
|
357
|
+
isIPOrLocalhost: true,
|
|
358
|
+
hasPort: true,
|
|
359
|
+
},
|
|
360
|
+
// localhost without port
|
|
361
|
+
{
|
|
362
|
+
type: LOCALHOST,
|
|
363
|
+
regexp: /^(localhost)$/,
|
|
364
|
+
isIPOrLocalhost: true,
|
|
365
|
+
hasPort: false,
|
|
366
|
+
},
|
|
367
|
+
// hostname plus port
|
|
368
|
+
{
|
|
369
|
+
type: null,
|
|
370
|
+
regexp: /(.+):\d+/,
|
|
371
|
+
isIPOrLocalhost: false, // False means the hostname may have a subdomain
|
|
372
|
+
hasPort: true,
|
|
373
|
+
},
|
|
374
|
+
];
|
|
375
|
+
/**
|
|
376
|
+
* Given a hostname that may be a hostname or ip address optionally
|
|
377
|
+
* followed by a port, return an object with 'host' being the ip address,
|
|
378
|
+
* the hostname, a port (if there is one), and 'isIPOrLocalhost' true for an ip
|
|
379
|
+
* address or localhost, false for a hostname
|
|
380
|
+
* @private
|
|
381
|
+
* @param host Can be localhost, an IP or domain name
|
|
382
|
+
* @return ParsedHost object
|
|
383
|
+
*/
|
|
384
|
+
export const parseHost = (host) => {
|
|
385
|
+
for (const test of HOST_AND_PORT_TESTS) {
|
|
386
|
+
const match = test.regexp.exec(host);
|
|
387
|
+
if (!match) {
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const result = {
|
|
391
|
+
host,
|
|
392
|
+
hostname: match[1],
|
|
393
|
+
isIPOrLocalhost: test.isIPOrLocalhost,
|
|
394
|
+
};
|
|
395
|
+
// Split apart and get the hostname and port from the host
|
|
396
|
+
if (test.hasPort) {
|
|
397
|
+
let parts;
|
|
398
|
+
switch (test.type) {
|
|
399
|
+
case IPV6:
|
|
400
|
+
// Filter out empty nodes fromt he array. We only care about
|
|
401
|
+
// the ones that have values
|
|
402
|
+
parts = result.host.split(test.regexp).filter((part) => part.length > 0);
|
|
403
|
+
break;
|
|
404
|
+
case IPV4:
|
|
405
|
+
case LOCALHOST:
|
|
406
|
+
default:
|
|
407
|
+
parts = result.host.split(':');
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
[result.hostname, result.port] = parts;
|
|
411
|
+
}
|
|
412
|
+
return result;
|
|
413
|
+
}
|
|
414
|
+
return {
|
|
415
|
+
host,
|
|
416
|
+
hostname: host,
|
|
417
|
+
isIPOrLocalhost: false,
|
|
418
|
+
};
|
|
419
|
+
};
|
|
420
|
+
// Cookie domain rewrite logic for rewriteSetCookies below
|
|
421
|
+
export const rewriteDomain = (domain, appHostname, targetHost) => {
|
|
422
|
+
// Strip any leading dots off the domain and split into elements
|
|
423
|
+
const domainElements = domain.split('.').filter((x) => x);
|
|
424
|
+
const domainString = domainElements.join('.');
|
|
425
|
+
// Does the appHostname include a port number? We need a version
|
|
426
|
+
// of it without the port (hostname) because set-cookie domains cannot
|
|
427
|
+
// include ports. We can't just test for ':' because the host might be an
|
|
428
|
+
// ipv6 address. An ipv6 address containing a port contains the
|
|
429
|
+
// actual IP surrounded by [] (e.g. [2001:db8::1]:8080)
|
|
430
|
+
// RFC3986
|
|
431
|
+
const parsedHost = parseHost(appHostname);
|
|
432
|
+
// If the target host equals or ends with the domainString
|
|
433
|
+
// value, then we change the domain to be the appHostname
|
|
434
|
+
// (though for localhost, we strip off the port number)
|
|
435
|
+
if (targetHost === domainString) {
|
|
436
|
+
// Straight replacement
|
|
437
|
+
return parsedHost.hostname;
|
|
438
|
+
}
|
|
439
|
+
if (!targetHost.endsWith(domainString)) {
|
|
440
|
+
// Third-party cookie... leave unchanged
|
|
441
|
+
return domain;
|
|
442
|
+
}
|
|
443
|
+
// Cookie is set for a subdomain.
|
|
444
|
+
if (parsedHost.isIPOrLocalhost) {
|
|
445
|
+
// No subdomains for IP addresses or localhost, so return just that domain
|
|
446
|
+
return parsedHost.hostname;
|
|
447
|
+
}
|
|
448
|
+
// This is tricky... there's no standard way to get the domain for
|
|
449
|
+
// a hostname. We use a shortcut - we build up a subdomain based on the
|
|
450
|
+
// appHost that has the same number of elements as the cookie domain.
|
|
451
|
+
const targetHostElements = targetHost.split('.');
|
|
452
|
+
// Work out how many elements have been removed to form the subdomain
|
|
453
|
+
const strippedOff = targetHostElements.length - domainElements.length;
|
|
454
|
+
// Strip the same number of elements off the appHost
|
|
455
|
+
const appHostnameElements = parsedHost.hostname.split('.');
|
|
456
|
+
return appHostnameElements.slice(strippedOff).join('.');
|
|
457
|
+
};
|
|
458
|
+
/**
|
|
459
|
+
* Given a headers object, rewrite any set-cookie headers in it
|
|
460
|
+
* so that they apply to the app hostname rather than the target
|
|
461
|
+
* hostname.
|
|
462
|
+
*
|
|
463
|
+
* @private
|
|
464
|
+
* @param params Configuration object for rewriting set-cookies
|
|
465
|
+
* @returns string[] of rewritten set-cookie header values
|
|
466
|
+
*/
|
|
467
|
+
export const rewriteSetCookies = ({ appHostname, setCookies, targetHost, logging = false, }) => {
|
|
468
|
+
if (!(setCookies && setCookies.length)) {
|
|
469
|
+
return [];
|
|
470
|
+
}
|
|
471
|
+
// Parse the set-cookie headers into a set of objects
|
|
472
|
+
const oldCookies = parseSetCookie(setCookies, { decodeValues: false });
|
|
473
|
+
// Map the oldCookies array into an array of updated objects
|
|
474
|
+
const newCookies = oldCookies.map((cookie) => {
|
|
475
|
+
if (cookie.domain) {
|
|
476
|
+
const newDomain = rewriteDomain(cookie.domain, appHostname, targetHost);
|
|
477
|
+
/* istanbul ignore next */
|
|
478
|
+
if (logging) {
|
|
479
|
+
console.log(`Rewriting proxy response set-cookie header domain from "${cookie.domain}" to "${newDomain}"`);
|
|
480
|
+
}
|
|
481
|
+
cookie.domain = newDomain;
|
|
482
|
+
}
|
|
483
|
+
return cookie;
|
|
484
|
+
});
|
|
485
|
+
// Convert the cookies back to string values
|
|
486
|
+
return newCookies.map(cookieAsString);
|
|
487
|
+
};
|
|
488
|
+
/**
|
|
489
|
+
* Rewrite headers for a proxied response.
|
|
490
|
+
*
|
|
491
|
+
* 1. If the original domain appears in the
|
|
492
|
+
* Access-Control-Allow-Origin header, it's replaced with the
|
|
493
|
+
* appOrigin.
|
|
494
|
+
* 2. If the response is a 30x redirection and contains a Location
|
|
495
|
+
* header on the target host, that header is rewritten to use the
|
|
496
|
+
* app host and proxy path.
|
|
497
|
+
*
|
|
498
|
+
* For a caching proxy, we also remove any Set-Cookie headers - caching
|
|
499
|
+
* proxies don't pass Cookie headers for requests and don't allow Set-Cookie
|
|
500
|
+
* in responses, so that they may be cached independently of any cookie
|
|
501
|
+
* values.
|
|
502
|
+
*
|
|
503
|
+
* @private
|
|
504
|
+
* @param params Configuration object for rewriting proxy response headers
|
|
505
|
+
* @returns the modified response headers
|
|
506
|
+
*/
|
|
507
|
+
export const rewriteProxyResponseHeaders = ({ appHostname, caching, headers, headerFormat = 'http', proxyPath, requestUrl, statusCode = 200, targetProtocol, targetHost, appProtocol = 'https', logging = false, }) => {
|
|
508
|
+
const workingHeaders = new Headers(headers ? { ...headers } : {}, headerFormat);
|
|
509
|
+
const appOrigin = `${appProtocol}://${appHostname}`;
|
|
510
|
+
const targetOrigin = `${targetProtocol}://${targetHost}`;
|
|
511
|
+
// Set the X-Proxy-Request-Url header, if we have the request URL
|
|
512
|
+
if (requestUrl) {
|
|
513
|
+
// If the requestUrl is just a path, prepend the targetOrigin.
|
|
514
|
+
// Including the full URL as a header value risks exceeding limits
|
|
515
|
+
// on header value sizes. CloudFront limits URLs to 8192 bytes.
|
|
516
|
+
// Even though API Gateway has a header value limit of
|
|
517
|
+
// 10240 bytes, we choose to limit the length of the header
|
|
518
|
+
// value to 8192 bytes.
|
|
519
|
+
const fullRequestUrl = (requestUrl.startsWith('/') ? `${targetOrigin}${requestUrl}` : requestUrl).slice(0, MAX_URL_LENGTH_BYTES);
|
|
520
|
+
if (logging) {
|
|
521
|
+
console.log(`Setting proxy response ${X_PROXY_REQUEST_URL} header to "${fullRequestUrl}"`);
|
|
522
|
+
}
|
|
523
|
+
workingHeaders.setHeader(X_PROXY_REQUEST_URL, fullRequestUrl);
|
|
524
|
+
}
|
|
525
|
+
// Get a version of the proxyPath that does not end in a slash
|
|
526
|
+
/* istanbul ignore next */
|
|
527
|
+
const proxyPathBase = proxyPath.endsWith('/') ? proxyPath.slice(0, -1) : proxyPath;
|
|
528
|
+
const allowOrigin = workingHeaders.getHeader(AC_ALLOW_ORIGIN);
|
|
529
|
+
if (logging && allowOrigin) {
|
|
530
|
+
console.log(`Header ${AC_ALLOW_ORIGIN} has value "${allowOrigin}"`);
|
|
531
|
+
}
|
|
532
|
+
if (allowOrigin === targetOrigin) {
|
|
533
|
+
/* istanbul ignore else */
|
|
534
|
+
if (logging) {
|
|
535
|
+
console.log(`Rewriting proxy response ${AC_ALLOW_ORIGIN} header to "${appOrigin}"`);
|
|
536
|
+
}
|
|
537
|
+
workingHeaders.setHeader(AC_ALLOW_ORIGIN, appOrigin);
|
|
538
|
+
}
|
|
539
|
+
if (caching) {
|
|
540
|
+
// For a caching proxy, remove any Set-Cookie headers
|
|
541
|
+
workingHeaders.deleteHeader(SET_COOKIE);
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
// For a standard proxy, rewrite domains in any set-cookie headers.
|
|
545
|
+
const updatedCookies = rewriteSetCookies({
|
|
546
|
+
appHostname,
|
|
547
|
+
setCookies: workingHeaders.getSetCookie(),
|
|
548
|
+
targetHost,
|
|
549
|
+
logging,
|
|
550
|
+
});
|
|
551
|
+
workingHeaders.setSetCookie(updatedCookies);
|
|
552
|
+
}
|
|
553
|
+
// Handle any redirect
|
|
554
|
+
if (statusCode >= 301 && statusCode <= 308) {
|
|
555
|
+
if (logging) {
|
|
556
|
+
console.log(`Status code is ${statusCode}, checking Location header`);
|
|
557
|
+
}
|
|
558
|
+
const location = workingHeaders.getHeader(LOCATION);
|
|
559
|
+
if (logging) {
|
|
560
|
+
console.log(`Location header has value "${location}"`);
|
|
561
|
+
}
|
|
562
|
+
/* istanbul ignore else */
|
|
563
|
+
if (location) {
|
|
564
|
+
// The Location header is defined as a URL, meaning that it
|
|
565
|
+
// can be both protocol- and host-relative, so we expand it
|
|
566
|
+
// relative to the targetOrigin.
|
|
567
|
+
const locUrl = new URL(location, targetOrigin);
|
|
568
|
+
// If the location header URL is on the targetOrigin, we rewrite it.
|
|
569
|
+
if (locUrl.protocol === `${targetProtocol}:` && locUrl.host === targetHost) {
|
|
570
|
+
// Rewrite the Location value to map to the proxy path
|
|
571
|
+
// on the app host.
|
|
572
|
+
locUrl.protocol = appProtocol;
|
|
573
|
+
locUrl.host = appHostname;
|
|
574
|
+
// Since the proxyPath ends with a slash and locUrl.pathname
|
|
575
|
+
// will start with a slash, we need to
|
|
576
|
+
locUrl.pathname = proxyPathBase + locUrl.pathname;
|
|
577
|
+
const newLocation = locUrl.toString();
|
|
578
|
+
workingHeaders.setHeader(LOCATION, newLocation);
|
|
579
|
+
/* istanbul ignore else */
|
|
580
|
+
if (logging) {
|
|
581
|
+
console.log(`Rewriting proxy response Location header from "${location}" to "${newLocation}"`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return workingHeaders.toObject();
|
|
587
|
+
};
|
|
588
|
+
/**
|
|
589
|
+
* List of x- headers that are removed from proxied requests.
|
|
590
|
+
* @private
|
|
591
|
+
*/
|
|
592
|
+
export const X_HEADERS_TO_REMOVE_PROXY = ['x-mobify-access-key', 'x-sfdc-access-control'];
|
|
593
|
+
/**
|
|
594
|
+
* List of x- headers that are removed from origin requests.
|
|
595
|
+
* @private
|
|
596
|
+
*/
|
|
597
|
+
export const X_HEADERS_TO_REMOVE_ORIGIN = [
|
|
598
|
+
'x-api-key',
|
|
599
|
+
'x-apigateway-event',
|
|
600
|
+
'x-apigateway-context',
|
|
601
|
+
'x-mobify-access-key',
|
|
602
|
+
'x-sfdc-access-control',
|
|
603
|
+
];
|
|
604
|
+
/**
|
|
605
|
+
* X-header key and values to add to proxied requests
|
|
606
|
+
* @private
|
|
607
|
+
*/
|
|
608
|
+
export const X_HEADERS_TO_ADD = {
|
|
609
|
+
'x-mobify': 'true',
|
|
610
|
+
};
|
|
611
|
+
/**
|
|
612
|
+
* List of headers that are allowed for a caching proxy request.
|
|
613
|
+
* This must match the allowlist that CloudFront uses for a
|
|
614
|
+
* CacheBehavior that does not pass cookies and is not configured
|
|
615
|
+
* to cache based on headers.
|
|
616
|
+
*
|
|
617
|
+
* This is a map from lower-case header name to 'true' - we use an object
|
|
618
|
+
* to make lookups fast, since this mapping might be used for many requests.
|
|
619
|
+
*
|
|
620
|
+
* Also see what is configured in the SSR Manager (ssr-infrastructure repo),
|
|
621
|
+
* in the CloudFront configuration. This list is a superset of that list,
|
|
622
|
+
* since the proxying code must also allow headers that it adds, such as
|
|
623
|
+
* Host, Origin, etc.
|
|
624
|
+
*
|
|
625
|
+
* See https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/header-caching.html
|
|
626
|
+
* See https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Cookies.html
|
|
627
|
+
*
|
|
628
|
+
* @private
|
|
629
|
+
*/
|
|
630
|
+
export const ALLOWED_CACHING_PROXY_REQUEST_HEADERS = {
|
|
631
|
+
// This is the set of headers allowed for CloudFront
|
|
632
|
+
accept: true,
|
|
633
|
+
'accept-charset': true,
|
|
634
|
+
'accept-encoding': true,
|
|
635
|
+
'accept-language': true,
|
|
636
|
+
authorization: true,
|
|
637
|
+
range: true,
|
|
638
|
+
// These headers must be preserved in the request so that
|
|
639
|
+
// header processing works correctly.
|
|
640
|
+
host: true,
|
|
641
|
+
origin: true,
|
|
642
|
+
// Where CloudFront does the proxying, these headers are
|
|
643
|
+
// generated by CloudFront itself. Where the Express app
|
|
644
|
+
// does it, we forward them.
|
|
645
|
+
'if-match': true,
|
|
646
|
+
'if-modified-since': true,
|
|
647
|
+
'if-none-match': true,
|
|
648
|
+
'if-range': true,
|
|
649
|
+
'if-unmodified-since': true,
|
|
650
|
+
};
|
|
651
|
+
/**
|
|
652
|
+
* Rewrite headers for a request that is being proxied.
|
|
653
|
+
*
|
|
654
|
+
* 1. If the request contains a Host header, rewrite it so that the
|
|
655
|
+
* value is the target host.
|
|
656
|
+
* 2. If the request contains an Origin header, rewrite it so that the
|
|
657
|
+
* value is the target host.
|
|
658
|
+
* 3. ALL other header values are left unchanged. If they are multi-value
|
|
659
|
+
* headers whose values are stored as arrays, the values are left as arrays.
|
|
660
|
+
*
|
|
661
|
+
* @private
|
|
662
|
+
* @param params Configuration object for rewriting proxy request headers
|
|
663
|
+
* @returns the modified request headers
|
|
664
|
+
*/
|
|
665
|
+
export const rewriteProxyRequestHeaders = ({ caching = false, headers, headerFormat = 'http', targetProtocol, targetHost, logging = false, }) => {
|
|
666
|
+
if (!headers) {
|
|
667
|
+
return {};
|
|
668
|
+
}
|
|
669
|
+
const workingHeaders = new Headers({ ...headers }, headerFormat);
|
|
670
|
+
// Strip out some specific X-headers
|
|
671
|
+
X_HEADERS_TO_REMOVE_PROXY.forEach((key) => workingHeaders.deleteHeader(key));
|
|
672
|
+
// For a caching proxy, apply special header processing
|
|
673
|
+
if (caching) {
|
|
674
|
+
// Remove any headers that are not on the allowlist
|
|
675
|
+
workingHeaders.keys().forEach((key) => {
|
|
676
|
+
if (!ALLOWED_CACHING_PROXY_REQUEST_HEADERS[key]) {
|
|
677
|
+
workingHeaders.deleteHeader(key);
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
// Override user-agent - mimic the behaviour of CloudFront
|
|
681
|
+
workingHeaders.setHeader(USER_AGENT, 'Amazon CloudFront');
|
|
682
|
+
}
|
|
683
|
+
// Fix up any Host header. We ignore any current value and
|
|
684
|
+
// always replace it with the target host.
|
|
685
|
+
// Host: <host>:<port>
|
|
686
|
+
const hostHeader = workingHeaders.getHeader(HOST);
|
|
687
|
+
if (hostHeader !== targetHost) {
|
|
688
|
+
/* istanbul ignore else */
|
|
689
|
+
if (logging) {
|
|
690
|
+
console.log(`Rewriting proxy request Host header from "${hostHeader}" to "${targetHost}"`);
|
|
691
|
+
}
|
|
692
|
+
workingHeaders.setHeader(HOST, targetHost);
|
|
693
|
+
}
|
|
694
|
+
// Fix up any Origin header. We ignore any current value and
|
|
695
|
+
// always replace it with the targetOrigin
|
|
696
|
+
// Origin: <scheme> "://" <hostname> [ ":" <port> ]
|
|
697
|
+
const originHeader = workingHeaders.getHeader(ORIGIN);
|
|
698
|
+
const targetOrigin = `${targetProtocol}://${targetHost}`;
|
|
699
|
+
if (originHeader && originHeader !== targetOrigin) {
|
|
700
|
+
workingHeaders.setHeader(ORIGIN, targetOrigin);
|
|
701
|
+
}
|
|
702
|
+
// Replace some headers with hardwired values
|
|
703
|
+
if (workingHeaders.getHeader(USER_AGENT)) {
|
|
704
|
+
// Mimic the behaviour of CloudFront
|
|
705
|
+
workingHeaders.setHeader(USER_AGENT, 'Amazon CloudFront');
|
|
706
|
+
}
|
|
707
|
+
// Add some specific X-headers
|
|
708
|
+
Object.entries(X_HEADERS_TO_ADD).forEach(([key, value]) => {
|
|
709
|
+
workingHeaders.setHeader(key, value);
|
|
710
|
+
});
|
|
711
|
+
return workingHeaders.toObject();
|
|
712
|
+
};
|
|
713
|
+
//# sourceMappingURL=ssr-proxying.js.map
|