@jsenv/server 15.5.3 → 16.0.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 +79 -86
- package/dist/html/4xx.html +196 -0
- package/dist/html/500.html +17 -0
- package/dist/html/route_inspector.html +2435 -0
- package/dist/js/formidable.js +17 -0
- package/dist/js/vendors.js +2001 -0
- package/dist/js/ws.js +6863 -0
- package/dist/jsenv_server.js +8829 -0
- package/package.json +19 -9
- package/src/content_negotiation/pick_content_version.js +63 -0
- package/src/{fetch_filesystem.js → filesystem/fetch_filesystem.js} +57 -72
- package/src/{serve_directory.js → filesystem/serve_directory.js} +1 -2
- package/src/interfacing_with_node/from_node_request.js +455 -0
- package/src/interfacing_with_node/get_observable_value_type.js +43 -0
- package/src/interfacing_with_node/observable.js +91 -75
- package/src/interfacing_with_node/observable_from.js +34 -0
- package/src/interfacing_with_node/observable_from_file_handle.js +30 -0
- package/src/interfacing_with_node/observable_from_node_stream.js +44 -46
- package/src/interfacing_with_node/observable_from_node_web_readable_stream.js +32 -0
- package/src/interfacing_with_node/observable_from_promise.js +13 -0
- package/src/interfacing_with_node/write_node_response.js +80 -35
- package/src/internal/colorizeResponseStatus.js +2 -0
- package/src/internal/headers_composition.js +42 -15
- package/src/internal/multiple-header.js +27 -12
- package/src/internal/object_composition.js +2 -2
- package/src/internal/response_composition.js +16 -3
- package/src/main.js +15 -11
- package/src/progressive_response.js +38 -0
- package/src/replace_placeholder_in_html.js +12 -0
- package/src/request_body_handling.js +22 -0
- package/src/router/router.js +878 -0
- package/src/server_events.js +431 -0
- package/src/service_controller.js +79 -73
- package/src/services/autoreload_on_server_restart/client/autoreload_on_server_restart.js +125 -0
- package/src/services/autoreload_on_server_restart/jsenv_service_autoreload_on_server_restart.js +45 -0
- package/src/services/cors/jsenv_service_cors.js +10 -18
- package/src/services/default_body_4xx_5xx/client/4xx.html +65 -0
- package/src/services/default_body_4xx_5xx/jsenv_service_default_body_4xx_5xx.js +82 -0
- package/src/services/error_handler/client/500.html +17 -0
- package/src/services/error_handler/jsenv_service_error_handler.js +47 -28
- package/src/services/internal_client_files/jsenv_service_internal_client_files.js +34 -0
- package/src/services/open_file/jsenv_service_open_file.js +37 -0
- package/src/services/route_inspector/client/code_block.js +215 -0
- package/src/services/route_inspector/client/route_inspector.html +1363 -0
- package/src/services/route_inspector/jsenv_service_route_inspector.js +39 -0
- package/src/start_server.js +575 -607
- package/src/web_socket_response.js +53 -0
- package/src/from_fetch_response.js +0 -15
- package/src/interfacing_with_node/body.js +0 -81
- package/src/interfacing_with_node/request_factory.js +0 -125
- package/src/readRequestBody.js +0 -32
- package/src/server_timing/timing_measure.js +0 -26
- package/src/service_composition/path_to_regexp.js +0 -436
- package/src/service_composition/routing.js +0 -31
- package/src/sse/sse_room.js +0 -313
|
@@ -0,0 +1,878 @@
|
|
|
1
|
+
import { parseFunction } from "@jsenv/assert/src/utils/function_parser.js";
|
|
2
|
+
import { lookupPackageDirectory } from "@jsenv/filesystem";
|
|
3
|
+
import { createHeadersPattern } from "@jsenv/router/src/shared/headers_pattern.js";
|
|
4
|
+
import { PATTERN } from "@jsenv/router/src/shared/pattern.js";
|
|
5
|
+
import { createResourcePattern } from "@jsenv/router/src/shared/resource_pattern.js";
|
|
6
|
+
import { resourceToExtension, urlToRelativeUrl } from "@jsenv/urls";
|
|
7
|
+
import { CONTENT_TYPE } from "@jsenv/utils/src/content_type/content_type.js";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { pickContentEncoding } from "../content_negotiation/pick_content_encoding.js";
|
|
10
|
+
import { pickContentLanguage } from "../content_negotiation/pick_content_language.js";
|
|
11
|
+
import { pickContentType } from "../content_negotiation/pick_content_type.js";
|
|
12
|
+
import { pickContentVersion } from "../content_negotiation/pick_content_version.js";
|
|
13
|
+
|
|
14
|
+
const routeInspectorUrl = `/.internal/route_inspector`;
|
|
15
|
+
|
|
16
|
+
const HTTP_METHODS = [
|
|
17
|
+
"OPTIONS",
|
|
18
|
+
"HEAD",
|
|
19
|
+
"GET",
|
|
20
|
+
"POST",
|
|
21
|
+
"PATCH",
|
|
22
|
+
"PUT",
|
|
23
|
+
"DELETE",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export const createRouter = (
|
|
27
|
+
routeDescriptionArray,
|
|
28
|
+
{ optionsFallback } = {},
|
|
29
|
+
) => {
|
|
30
|
+
const routeSet = new Set();
|
|
31
|
+
|
|
32
|
+
const constructAvailableEndpoints = () => {
|
|
33
|
+
// TODO: memoize
|
|
34
|
+
// TODO: construct only if the route is visible to that client
|
|
35
|
+
const availableEndpoints = [];
|
|
36
|
+
const createEndpoint = ({ method, resource }) => {
|
|
37
|
+
return {
|
|
38
|
+
method,
|
|
39
|
+
resource,
|
|
40
|
+
toString: () => {
|
|
41
|
+
return `${method} ${resource}`;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
for (const route of routeSet) {
|
|
47
|
+
const endpointResource = route.resourcePattern.generateExample();
|
|
48
|
+
if (route.method === "*") {
|
|
49
|
+
for (const HTTP_METHOD of HTTP_METHODS) {
|
|
50
|
+
availableEndpoints.push(
|
|
51
|
+
createEndpoint({
|
|
52
|
+
method: HTTP_METHOD,
|
|
53
|
+
resource: endpointResource,
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
availableEndpoints.push(
|
|
59
|
+
createEndpoint({
|
|
60
|
+
method: route.method,
|
|
61
|
+
resource: endpointResource,
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return availableEndpoints;
|
|
67
|
+
};
|
|
68
|
+
const createResourceOptions = () => {
|
|
69
|
+
const acceptedMediaTypeSet = new Set();
|
|
70
|
+
const postAcceptedMediaTypeSet = new Set();
|
|
71
|
+
const patchAcceptedMediaTypeSet = new Set();
|
|
72
|
+
const allowedMethodSet = new Set();
|
|
73
|
+
return {
|
|
74
|
+
onMethodAllowed: (route, method) => {
|
|
75
|
+
allowedMethodSet.add(method);
|
|
76
|
+
for (const acceptedMediaType of route.acceptedMediaTypes) {
|
|
77
|
+
acceptedMediaTypeSet.add(acceptedMediaType);
|
|
78
|
+
if (method === "POST") {
|
|
79
|
+
postAcceptedMediaTypeSet.add(acceptedMediaType);
|
|
80
|
+
}
|
|
81
|
+
if (method === "PATCH") {
|
|
82
|
+
patchAcceptedMediaTypeSet.add(acceptedMediaType);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
asResponseHeaders: () => {
|
|
87
|
+
const headers = {};
|
|
88
|
+
if (acceptedMediaTypeSet.size) {
|
|
89
|
+
headers["accept"] = Array.from(acceptedMediaTypeSet).join(", ");
|
|
90
|
+
}
|
|
91
|
+
if (postAcceptedMediaTypeSet.size) {
|
|
92
|
+
headers["accept-post"] = Array.from(postAcceptedMediaTypeSet).join(
|
|
93
|
+
", ",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (patchAcceptedMediaTypeSet.size) {
|
|
97
|
+
headers["accept-patch"] = Array.from(patchAcceptedMediaTypeSet).join(
|
|
98
|
+
", ",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (allowedMethodSet.size) {
|
|
102
|
+
headers["allow"] = Array.from(allowedMethodSet).join(", ");
|
|
103
|
+
}
|
|
104
|
+
return headers;
|
|
105
|
+
},
|
|
106
|
+
toJSON: () => {
|
|
107
|
+
return {
|
|
108
|
+
acceptedMediaTypes: Array.from(acceptedMediaTypeSet),
|
|
109
|
+
postAcceptedMediaTypes: Array.from(postAcceptedMediaTypeSet),
|
|
110
|
+
patchAcceptedMediaTypes: Array.from(patchAcceptedMediaTypeSet),
|
|
111
|
+
allowedMethods: Array.from(allowedMethodSet),
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
const forEachMethodAllowed = (route, onMethodAllowed) => {
|
|
117
|
+
const supportedMethods =
|
|
118
|
+
route.method === "*" ? HTTP_METHODS : [route.method];
|
|
119
|
+
for (const supportedMethod of supportedMethods) {
|
|
120
|
+
onMethodAllowed(supportedMethod);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const inferResourceOPTIONS = (request) => {
|
|
124
|
+
const resourceOptions = createResourceOptions();
|
|
125
|
+
for (const route of routeSet) {
|
|
126
|
+
if (!route.matchResource(request.resource)) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const accessControlRequestMethodHeader =
|
|
130
|
+
request.headers["access-control-request-method"];
|
|
131
|
+
if (accessControlRequestMethodHeader) {
|
|
132
|
+
if (route.matchMethod(accessControlRequestMethodHeader)) {
|
|
133
|
+
resourceOptions.onMethodAllowed(
|
|
134
|
+
route,
|
|
135
|
+
accessControlRequestMethodHeader,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
forEachMethodAllowed(route, (methodAllowed) => {
|
|
140
|
+
resourceOptions.onMethodAllowed(route, methodAllowed);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return resourceOptions;
|
|
145
|
+
};
|
|
146
|
+
const inferServerOPTIONS = () => {
|
|
147
|
+
const serverOptions = createResourceOptions();
|
|
148
|
+
const resourceOptionsMap = new Map();
|
|
149
|
+
|
|
150
|
+
for (const route of routeSet) {
|
|
151
|
+
const routeResource = route.resource;
|
|
152
|
+
let resourceOptions = resourceOptionsMap.get(routeResource);
|
|
153
|
+
if (!resourceOptions) {
|
|
154
|
+
resourceOptions = createResourceOptions();
|
|
155
|
+
resourceOptionsMap.set(routeResource, resourceOptions);
|
|
156
|
+
}
|
|
157
|
+
forEachMethodAllowed(route, (method) => {
|
|
158
|
+
serverOptions.onMethodAllowed(route, method);
|
|
159
|
+
resourceOptions.onMethodAllowed(route, method);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
server: serverOptions,
|
|
164
|
+
resourceOptionsMap,
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const router = {};
|
|
169
|
+
for (const routeDescription of routeDescriptionArray) {
|
|
170
|
+
const route = createRoute(routeDescription);
|
|
171
|
+
routeSet.add(route);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const match = async (request, fetchSecondArg) => {
|
|
175
|
+
fetchSecondArg.router = router;
|
|
176
|
+
const wouldHaveMatched = {
|
|
177
|
+
// in case nothing matches we can produce a response with Allow: GET, POST, PUT for example
|
|
178
|
+
methodSet: new Set(),
|
|
179
|
+
requestMediaTypeSet: new Set(),
|
|
180
|
+
responseMediaTypeSet: new Set(),
|
|
181
|
+
responseLanguageSet: new Set(),
|
|
182
|
+
responseVersionSet: new Set(),
|
|
183
|
+
responseEncodingSet: new Set(),
|
|
184
|
+
upgrade: false,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
let currentService;
|
|
188
|
+
let currentRoutingTiming;
|
|
189
|
+
const onRouteMatchStart = (route) => {
|
|
190
|
+
if (route.service === currentService) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
onRouteGroupEnd(route);
|
|
194
|
+
currentRoutingTiming = fetchSecondArg.timing(
|
|
195
|
+
route.service
|
|
196
|
+
? `${route.service.name.replace("jsenv:", "")}.routing`
|
|
197
|
+
: "routing",
|
|
198
|
+
);
|
|
199
|
+
currentService = route.service;
|
|
200
|
+
};
|
|
201
|
+
const onRouteGroupEnd = () => {
|
|
202
|
+
if (currentRoutingTiming) {
|
|
203
|
+
currentRoutingTiming.end();
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
const onRouteMatch = (route) => {
|
|
207
|
+
onRouteGroupEnd(route);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const checkResponseContentHeader = (route, responseHeaders, name) => {
|
|
211
|
+
const routePropertyName = {
|
|
212
|
+
type: "availableMediaTypes",
|
|
213
|
+
language: "availableLanguages",
|
|
214
|
+
version: "availableVersions",
|
|
215
|
+
encoding: "availableEncodings",
|
|
216
|
+
}[name];
|
|
217
|
+
const availableValues = route[routePropertyName];
|
|
218
|
+
if (availableValues.length === 0) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const responseHeaderName = {
|
|
222
|
+
type: "content-type",
|
|
223
|
+
language: "content-language",
|
|
224
|
+
version: "content-version",
|
|
225
|
+
encoding: "content-encoding",
|
|
226
|
+
}[name];
|
|
227
|
+
let responseHeaderValue = responseHeaders[responseHeaderName];
|
|
228
|
+
if (!responseHeaderValue) {
|
|
229
|
+
request.logger.warn(
|
|
230
|
+
`The response header ${responseHeaderName} is missing.
|
|
231
|
+
It should be set to one of route.${routePropertyName}: ${availableValues.join(", ")}.`,
|
|
232
|
+
);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (responseHeaderName === "content-type") {
|
|
236
|
+
responseHeaderValue = CONTENT_TYPE.asMediaType(responseHeaderValue);
|
|
237
|
+
}
|
|
238
|
+
if (
|
|
239
|
+
responseHeaderName === "content-version" &&
|
|
240
|
+
isFinite(responseHeaderValue)
|
|
241
|
+
) {
|
|
242
|
+
responseHeaderValue = parseFloat(responseHeaderValue);
|
|
243
|
+
}
|
|
244
|
+
let matchesAnAvailableValue = false;
|
|
245
|
+
for (const availableValue of availableValues) {
|
|
246
|
+
if (responseHeaderValue === availableValue) {
|
|
247
|
+
matchesAnAvailableValue = true;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
if (
|
|
251
|
+
typeof availableValue === "function" &&
|
|
252
|
+
availableValue(responseHeaderValue)
|
|
253
|
+
) {
|
|
254
|
+
matchesAnAvailableValue = true;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!matchesAnAvailableValue) {
|
|
260
|
+
request.logger.warn(
|
|
261
|
+
`The value "${responseHeaderValue}" found in response header ${responseHeaderName} is strange.
|
|
262
|
+
It should be should be one of route.${routePropertyName}: ${availableValues.join(", ")}.`,
|
|
263
|
+
);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
const onResponseHeaders = (request, route, responseHeaders) => {
|
|
268
|
+
checkResponseContentHeader(route, responseHeaders, "type");
|
|
269
|
+
checkResponseContentHeader(route, responseHeaders, "language");
|
|
270
|
+
checkResponseContentHeader(route, responseHeaders, "version");
|
|
271
|
+
checkResponseContentHeader(route, responseHeaders, "encoding");
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
for (const route of routeSet) {
|
|
275
|
+
onRouteMatchStart(route);
|
|
276
|
+
const resourceMatchResult = route.matchResource(request.resource);
|
|
277
|
+
if (!resourceMatchResult) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (!route.matchMethod(request.method)) {
|
|
281
|
+
if (!route.isFallback) {
|
|
282
|
+
wouldHaveMatched.methodSet.add(route.method);
|
|
283
|
+
}
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (
|
|
287
|
+
request.method === "POST" ||
|
|
288
|
+
request.method === "PATCH" ||
|
|
289
|
+
request.method === "PUT"
|
|
290
|
+
) {
|
|
291
|
+
const { acceptedMediaTypes } = route;
|
|
292
|
+
if (
|
|
293
|
+
acceptedMediaTypes.length &&
|
|
294
|
+
!isRequestBodyMediaTypeSupported(request, { acceptedMediaTypes })
|
|
295
|
+
) {
|
|
296
|
+
for (const acceptedMediaType of acceptedMediaTypes) {
|
|
297
|
+
wouldHaveMatched.requestMediaTypeSet.add(acceptedMediaType);
|
|
298
|
+
}
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const headersMatchResult = route.matchHeaders(request.headers);
|
|
303
|
+
if (!headersMatchResult) {
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (route.isForWebSocket && request.headers["upgrade"] !== "websocket") {
|
|
307
|
+
wouldHaveMatched.upgrade = true;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
// now we are "good", let's try to generate a response
|
|
311
|
+
const contentNegotiationResult = {};
|
|
312
|
+
content_negotiation: {
|
|
313
|
+
// when content nego fails
|
|
314
|
+
// we will check the remaining accept headers to properly inform client of all the things are failing
|
|
315
|
+
// Example:
|
|
316
|
+
// client says "I want text in french"
|
|
317
|
+
// but server only provide json in english
|
|
318
|
+
// we want to tell client both text and french are not available
|
|
319
|
+
let hasFailed = false;
|
|
320
|
+
const { availableMediaTypes } = route;
|
|
321
|
+
if (availableMediaTypes.length) {
|
|
322
|
+
fetchSecondArg.injectResponseHeader("vary", "accept");
|
|
323
|
+
if (request.headers["accept"]) {
|
|
324
|
+
const mediaTypeNegotiated = pickContentType(
|
|
325
|
+
request,
|
|
326
|
+
availableMediaTypes,
|
|
327
|
+
);
|
|
328
|
+
if (!mediaTypeNegotiated) {
|
|
329
|
+
for (const availableMediaType of availableMediaTypes) {
|
|
330
|
+
wouldHaveMatched.responseMediaTypeSet.add(availableMediaType);
|
|
331
|
+
}
|
|
332
|
+
hasFailed = true;
|
|
333
|
+
}
|
|
334
|
+
contentNegotiationResult.mediaType = mediaTypeNegotiated;
|
|
335
|
+
} else {
|
|
336
|
+
contentNegotiationResult.mediaType = availableMediaTypes[0];
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const { availableLanguages } = route;
|
|
340
|
+
if (availableLanguages.length) {
|
|
341
|
+
fetchSecondArg.injectResponseHeader("vary", "accept-language");
|
|
342
|
+
if (request.headers["accept-language"]) {
|
|
343
|
+
const languageNegotiated = pickContentLanguage(
|
|
344
|
+
request,
|
|
345
|
+
availableLanguages,
|
|
346
|
+
);
|
|
347
|
+
if (!languageNegotiated) {
|
|
348
|
+
for (const availableLanguage of availableLanguages) {
|
|
349
|
+
wouldHaveMatched.responseLanguageSet.add(availableLanguage);
|
|
350
|
+
}
|
|
351
|
+
hasFailed = true;
|
|
352
|
+
}
|
|
353
|
+
contentNegotiationResult.language = languageNegotiated;
|
|
354
|
+
} else {
|
|
355
|
+
contentNegotiationResult.language = availableLanguages[0];
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const { availableVersions } = route;
|
|
359
|
+
if (availableVersions.length) {
|
|
360
|
+
fetchSecondArg.injectResponseHeader("vary", "accept-version");
|
|
361
|
+
if (request.headers["accept-version"]) {
|
|
362
|
+
const versionNegotiated = pickContentVersion(
|
|
363
|
+
request,
|
|
364
|
+
availableVersions,
|
|
365
|
+
);
|
|
366
|
+
if (!versionNegotiated) {
|
|
367
|
+
for (const availableVersion of availableVersions) {
|
|
368
|
+
wouldHaveMatched.responseVersionSet.add(availableVersion);
|
|
369
|
+
}
|
|
370
|
+
hasFailed = true;
|
|
371
|
+
}
|
|
372
|
+
contentNegotiationResult.version = versionNegotiated;
|
|
373
|
+
} else {
|
|
374
|
+
contentNegotiationResult.version = availableVersions[0];
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const { availableEncodings } = route;
|
|
378
|
+
if (availableEncodings.length) {
|
|
379
|
+
fetchSecondArg.injectResponseHeader("vary", "accept-encoding");
|
|
380
|
+
if (request.headers["accept-encoding"]) {
|
|
381
|
+
const encodingNegotiated = pickContentEncoding(
|
|
382
|
+
request,
|
|
383
|
+
availableEncodings,
|
|
384
|
+
);
|
|
385
|
+
if (!encodingNegotiated) {
|
|
386
|
+
for (const availableEncoding of availableEncodings) {
|
|
387
|
+
wouldHaveMatched.responseEncodingSet.add(availableEncoding);
|
|
388
|
+
}
|
|
389
|
+
hasFailed = true;
|
|
390
|
+
}
|
|
391
|
+
contentNegotiationResult.encoding = encodingNegotiated;
|
|
392
|
+
} else {
|
|
393
|
+
contentNegotiationResult.encoding = availableEncodings[0];
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (hasFailed) {
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const { named, stars = [] } = PATTERN.composeTwoMatchResults(
|
|
401
|
+
resourceMatchResult,
|
|
402
|
+
headersMatchResult,
|
|
403
|
+
);
|
|
404
|
+
Object.assign(request.params, named, stars);
|
|
405
|
+
fetchSecondArg.contentNegotiation = contentNegotiationResult;
|
|
406
|
+
let fetchReturnValue = route.fetch(request, fetchSecondArg);
|
|
407
|
+
if (
|
|
408
|
+
fetchReturnValue !== null &&
|
|
409
|
+
typeof fetchReturnValue === "object" &&
|
|
410
|
+
typeof fetchReturnValue.then === "function"
|
|
411
|
+
) {
|
|
412
|
+
fetchReturnValue = await fetchReturnValue;
|
|
413
|
+
}
|
|
414
|
+
// route decided not to handle in the end
|
|
415
|
+
if (fetchReturnValue === null || fetchReturnValue === undefined) {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
onRouteMatch(route);
|
|
419
|
+
if (fetchReturnValue instanceof Response) {
|
|
420
|
+
const headers = Object.fromEntries(fetchReturnValue.headers);
|
|
421
|
+
onResponseHeaders(request, route, headers);
|
|
422
|
+
return {
|
|
423
|
+
status: fetchReturnValue.status,
|
|
424
|
+
statusText: fetchReturnValue.statusText,
|
|
425
|
+
headers,
|
|
426
|
+
body: fetchReturnValue.body,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
if (fetchReturnValue !== null && typeof fetchReturnValue === "object") {
|
|
430
|
+
const headers = fetchReturnValue.headers || {};
|
|
431
|
+
onResponseHeaders(request, route, headers);
|
|
432
|
+
return {
|
|
433
|
+
status: fetchReturnValue.status || 404,
|
|
434
|
+
statusText: fetchReturnValue.statusText,
|
|
435
|
+
statusMessage: fetchReturnValue.statusMessage,
|
|
436
|
+
headers,
|
|
437
|
+
body: fetchReturnValue.body,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
throw new TypeError(
|
|
441
|
+
`response must be a Response, or an Object, received ${fetchReturnValue}`,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
// nothing has matched fully
|
|
445
|
+
// if nothing matches at all we'll send 404
|
|
446
|
+
// but if url matched but METHOD was not supported we send 405
|
|
447
|
+
if (wouldHaveMatched.methodSet.size) {
|
|
448
|
+
return createMethodNotAllowedResponse(request, {
|
|
449
|
+
allowedMethods: [...wouldHaveMatched.methodSet],
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
if (wouldHaveMatched.requestMediaTypeSet.size) {
|
|
453
|
+
return createUnsupportedMediaTypeResponse(request, {
|
|
454
|
+
acceptedMediaTypes: [...wouldHaveMatched.requestMediaTypeSet],
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
if (
|
|
458
|
+
wouldHaveMatched.responseMediaTypeSet.size ||
|
|
459
|
+
wouldHaveMatched.responseLanguageSet.size ||
|
|
460
|
+
wouldHaveMatched.responseVersionSet.size ||
|
|
461
|
+
wouldHaveMatched.responseEncodingSet.size
|
|
462
|
+
) {
|
|
463
|
+
return createNotAcceptableResponse(request, {
|
|
464
|
+
availableMediaTypes: [...wouldHaveMatched.responseMediaTypeSet],
|
|
465
|
+
availableLanguages: [...wouldHaveMatched.responseLanguageSet],
|
|
466
|
+
availableVersions: [...wouldHaveMatched.responseVersionSet],
|
|
467
|
+
availableEncodings: [...wouldHaveMatched.responseEncodingSet],
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
if (wouldHaveMatched.upgrade) {
|
|
471
|
+
return {
|
|
472
|
+
status: 426,
|
|
473
|
+
statusText: "Upgrade Required",
|
|
474
|
+
statusMessage: `The request requires the upgrade to a webSocket connection`,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
const availableEndpoints = constructAvailableEndpoints(
|
|
478
|
+
request,
|
|
479
|
+
fetchSecondArg,
|
|
480
|
+
);
|
|
481
|
+
return createRouteNotFoundResponse(request, { availableEndpoints });
|
|
482
|
+
};
|
|
483
|
+
const inspect = () => {
|
|
484
|
+
// I want all the info I can gather about the routes
|
|
485
|
+
const data = [];
|
|
486
|
+
for (const route of routeSet) {
|
|
487
|
+
data.push(route.toJSON());
|
|
488
|
+
}
|
|
489
|
+
return data;
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
if (optionsFallback) {
|
|
493
|
+
const optionRouteFallback = createRoute({
|
|
494
|
+
endpoint: "OPTIONS *",
|
|
495
|
+
description:
|
|
496
|
+
"Auto generate an OPTIONS response about a resource or the whole server.",
|
|
497
|
+
declarationSource: import.meta.url,
|
|
498
|
+
fetch: (request, helpers) => {
|
|
499
|
+
const isForAnyRoute = request.resource === "*";
|
|
500
|
+
if (isForAnyRoute) {
|
|
501
|
+
const serverOPTIONS = inferServerOPTIONS(request, helpers);
|
|
502
|
+
return createServerResourceOptionsResponse(request, serverOPTIONS);
|
|
503
|
+
}
|
|
504
|
+
const resourceOPTIONS = inferResourceOPTIONS(request, helpers);
|
|
505
|
+
return createResourceOptionsResponse(request, resourceOPTIONS);
|
|
506
|
+
},
|
|
507
|
+
isFallback: true,
|
|
508
|
+
});
|
|
509
|
+
routeSet.add(optionRouteFallback);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
Object.assign(router, {
|
|
513
|
+
match,
|
|
514
|
+
inspect,
|
|
515
|
+
});
|
|
516
|
+
return router;
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Adds a route to the router.
|
|
521
|
+
*
|
|
522
|
+
* @param {Object} params - Route configuration object
|
|
523
|
+
* @param {string} params.endpoint - String in format "METHOD /resource/path" (e.g. "GET /users/:id")
|
|
524
|
+
* @param {Object} [params.headers] - Optional headers pattern to match
|
|
525
|
+
* @param {Array<string>} [params.availableMediaTypes=[]] - Content types this route can produce
|
|
526
|
+
* @param {Array<string>} [params.availableLanguages=[]] - Languages this route can respond with
|
|
527
|
+
* @param {Array<string>} [params.availableEncodings=[]] - Encodings this route supports
|
|
528
|
+
* @param {Array<string>} [params.acceptedMediaTypes=[]] - Content types this route accepts (for POST/PATCH/PUT)
|
|
529
|
+
* @param {Function} params.fetch - Function to generate response for matching requests
|
|
530
|
+
* @throws {TypeError} If endpoint is not a string
|
|
531
|
+
* @returns {void}
|
|
532
|
+
*/
|
|
533
|
+
const createRoute = ({
|
|
534
|
+
endpoint,
|
|
535
|
+
description,
|
|
536
|
+
headers,
|
|
537
|
+
service,
|
|
538
|
+
availableMediaTypes = [],
|
|
539
|
+
availableLanguages = [],
|
|
540
|
+
availableVersions = [],
|
|
541
|
+
availableEncodings = [],
|
|
542
|
+
acceptedMediaTypes = [], // useful only for POST/PATCH/PUT
|
|
543
|
+
fetch: routeFetchMethod, // rename because there is global.fetch and we want to be explicit
|
|
544
|
+
clientCodeExample,
|
|
545
|
+
isFallback,
|
|
546
|
+
subroutes,
|
|
547
|
+
declarationSource,
|
|
548
|
+
}) => {
|
|
549
|
+
if (!endpoint || typeof endpoint !== "string") {
|
|
550
|
+
throw new TypeError(`endpoint must be a string, received ${endpoint}`);
|
|
551
|
+
}
|
|
552
|
+
const [method, resource] = endpoint === "*" ? ["* *"] : endpoint.split(" ");
|
|
553
|
+
if (method !== "*" && !HTTP_METHODS.includes(method)) {
|
|
554
|
+
throw new TypeError(`"${method}" is not an HTTP method`);
|
|
555
|
+
}
|
|
556
|
+
if (resource[0] !== "/" && resource[0] !== "*") {
|
|
557
|
+
throw new TypeError(`resource must start with /, received ${resource}`);
|
|
558
|
+
}
|
|
559
|
+
if (typeof routeFetchMethod !== "function") {
|
|
560
|
+
throw new TypeError(
|
|
561
|
+
`fetch must be a function, received ${routeFetchMethod} on endpoint "${endpoint}"`,
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
const extension = resourceToExtension(resource);
|
|
565
|
+
const extensionWellKnownContentType = extension
|
|
566
|
+
? CONTENT_TYPE.fromExtension(extension)
|
|
567
|
+
: null;
|
|
568
|
+
if (
|
|
569
|
+
availableMediaTypes.length === 0 &&
|
|
570
|
+
extensionWellKnownContentType &&
|
|
571
|
+
extensionWellKnownContentType !== "application/octet-stream" // this is the default extension
|
|
572
|
+
) {
|
|
573
|
+
availableMediaTypes.push(extensionWellKnownContentType);
|
|
574
|
+
}
|
|
575
|
+
const resourcePattern = createResourcePattern(resource);
|
|
576
|
+
const headersPattern = headers ? createHeadersPattern(headers) : null;
|
|
577
|
+
|
|
578
|
+
const isForWebSocket =
|
|
579
|
+
(headers && headers["upgrade"] === "websocket") ||
|
|
580
|
+
extension === ".websocket";
|
|
581
|
+
|
|
582
|
+
const route = {
|
|
583
|
+
method,
|
|
584
|
+
resource,
|
|
585
|
+
description,
|
|
586
|
+
service,
|
|
587
|
+
availableMediaTypes,
|
|
588
|
+
availableLanguages,
|
|
589
|
+
availableVersions,
|
|
590
|
+
availableEncodings,
|
|
591
|
+
acceptedMediaTypes,
|
|
592
|
+
matchMethod:
|
|
593
|
+
method === "*" ? () => true : (requestMethod) => requestMethod === method,
|
|
594
|
+
matchResource:
|
|
595
|
+
resource === "*"
|
|
596
|
+
? () => true
|
|
597
|
+
: (requestResource) => {
|
|
598
|
+
return resourcePattern.match(requestResource);
|
|
599
|
+
},
|
|
600
|
+
matchHeaders:
|
|
601
|
+
headers === undefined
|
|
602
|
+
? () => true
|
|
603
|
+
: (requestHeaders) => {
|
|
604
|
+
return headersPattern.match(requestHeaders);
|
|
605
|
+
},
|
|
606
|
+
fetch: routeFetchMethod,
|
|
607
|
+
toString: () => {
|
|
608
|
+
return `${method} ${resource}`;
|
|
609
|
+
},
|
|
610
|
+
toJSON: () => {
|
|
611
|
+
const meta = {};
|
|
612
|
+
|
|
613
|
+
if (declarationSource) {
|
|
614
|
+
meta.declarationLink = {
|
|
615
|
+
url: `javascript:window.fetch("/.internal/open_file/${encodeURIComponent(declarationSource)}")`,
|
|
616
|
+
text: declarationSource,
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
const packageDirectory = lookupPackageDirectory(declarationSource);
|
|
620
|
+
if (packageDirectory) {
|
|
621
|
+
const packageFileUrl = new URL("package.json", packageDirectory);
|
|
622
|
+
try {
|
|
623
|
+
const packageFileText = readFileSync(packageFileUrl, "utf8");
|
|
624
|
+
const packageJson = JSON.parse(packageFileText);
|
|
625
|
+
const packageName = packageJson.name;
|
|
626
|
+
if (packageJson.name) {
|
|
627
|
+
meta.packageName = packageName;
|
|
628
|
+
meta.ownerLink = {
|
|
629
|
+
url: `javascript:window.fetch("/.internal/open_file/${encodeURIComponent(packageFileUrl)}")`,
|
|
630
|
+
text: packageName,
|
|
631
|
+
};
|
|
632
|
+
const declarationUrlRelativeToOwnerPackage = urlToRelativeUrl(
|
|
633
|
+
declarationSource,
|
|
634
|
+
packageFileUrl,
|
|
635
|
+
);
|
|
636
|
+
meta.declarationLink.text = `${packageName}/${declarationUrlRelativeToOwnerPackage}`;
|
|
637
|
+
}
|
|
638
|
+
} catch {}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
return {
|
|
643
|
+
method,
|
|
644
|
+
resource,
|
|
645
|
+
description,
|
|
646
|
+
availableMediaTypes,
|
|
647
|
+
availableLanguages,
|
|
648
|
+
availableVersions,
|
|
649
|
+
availableEncodings,
|
|
650
|
+
acceptedMediaTypes,
|
|
651
|
+
isForWebSocket,
|
|
652
|
+
clientCodeExample:
|
|
653
|
+
typeof clientCodeExample === "function"
|
|
654
|
+
? parseFunction(clientCodeExample).body
|
|
655
|
+
: typeof clientCodeExample === "string"
|
|
656
|
+
? clientCodeExample
|
|
657
|
+
: undefined,
|
|
658
|
+
declarationSource,
|
|
659
|
+
meta,
|
|
660
|
+
};
|
|
661
|
+
},
|
|
662
|
+
resourcePattern,
|
|
663
|
+
isForWebSocket,
|
|
664
|
+
isFallback,
|
|
665
|
+
subroutes,
|
|
666
|
+
};
|
|
667
|
+
return route;
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
const isRequestBodyMediaTypeSupported = (request, { acceptedMediaTypes }) => {
|
|
671
|
+
const requestBodyContentType = request.headers["content-type"];
|
|
672
|
+
if (!requestBodyContentType) {
|
|
673
|
+
return false;
|
|
674
|
+
}
|
|
675
|
+
for (const acceptedMediaType of acceptedMediaTypes) {
|
|
676
|
+
if (requestBodyContentType.includes(acceptedMediaType)) {
|
|
677
|
+
return true;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return false;
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
const createServerResourceOptionsResponse = (
|
|
684
|
+
request,
|
|
685
|
+
{ server, resourceOptionsMap },
|
|
686
|
+
) => {
|
|
687
|
+
const headers = server.asResponseHeaders();
|
|
688
|
+
const mediaTypeNegotiated = pickContentType(request, [
|
|
689
|
+
"application/json",
|
|
690
|
+
"text/plain",
|
|
691
|
+
]);
|
|
692
|
+
if (mediaTypeNegotiated === "application/json") {
|
|
693
|
+
const perResource = {};
|
|
694
|
+
for (const [resource, resourceOptions] of resourceOptionsMap) {
|
|
695
|
+
perResource[resource] = resourceOptions.toJSON();
|
|
696
|
+
}
|
|
697
|
+
return Response.json(
|
|
698
|
+
{
|
|
699
|
+
server: server.toJSON(),
|
|
700
|
+
perResource,
|
|
701
|
+
},
|
|
702
|
+
{ status: 200, headers },
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
// text/plain
|
|
706
|
+
return new Response(
|
|
707
|
+
`The list of endpoints available can be seen at ${routeInspectorUrl}`,
|
|
708
|
+
{ status: 200, headers },
|
|
709
|
+
);
|
|
710
|
+
};
|
|
711
|
+
const createResourceOptionsResponse = (request, resourceOptions) => {
|
|
712
|
+
const headers = resourceOptions.asResponseHeaders();
|
|
713
|
+
return new Response(undefined, { status: 204, headers });
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Creates a 406 Not Acceptable response when content negotiation fails
|
|
718
|
+
*
|
|
719
|
+
* @param {Object} request - The HTTP request object
|
|
720
|
+
* @param {Object} params - Content negotiation parameters
|
|
721
|
+
* @param {Array<string>} params.availableMediaTypes - Content types the server can produce
|
|
722
|
+
* @param {Array<string>} params.availableLanguages - Languages the server can respond with
|
|
723
|
+
* @param {Array<string>} params.availableEncodings - Encodings the server supports
|
|
724
|
+
* @returns {Response} A 406 Not Acceptable response
|
|
725
|
+
*/
|
|
726
|
+
const createNotAcceptableResponse = (
|
|
727
|
+
request,
|
|
728
|
+
{
|
|
729
|
+
availableMediaTypes,
|
|
730
|
+
availableLanguages,
|
|
731
|
+
availableVersions,
|
|
732
|
+
availableEncodings,
|
|
733
|
+
},
|
|
734
|
+
) => {
|
|
735
|
+
const unsupported = [];
|
|
736
|
+
const headers = {};
|
|
737
|
+
const data = {};
|
|
738
|
+
|
|
739
|
+
if (availableMediaTypes.length) {
|
|
740
|
+
const requestAcceptHeader = request.headers["accept"];
|
|
741
|
+
|
|
742
|
+
// Use a non-standard but semantic header name
|
|
743
|
+
headers["available-media-types"] = availableMediaTypes.join(", ");
|
|
744
|
+
|
|
745
|
+
Object.assign(data, {
|
|
746
|
+
requestAcceptHeader,
|
|
747
|
+
availableMediaTypes,
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
unsupported.push({
|
|
751
|
+
type: "content-type",
|
|
752
|
+
message: `The server cannot produce a response in any of the media types accepted by the request: "${requestAcceptHeader}".
|
|
753
|
+
Available media types: ${availableMediaTypes.join(", ")}.`,
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
if (availableLanguages.length) {
|
|
757
|
+
const requestAcceptLanguageHeader = request.headers["accept-language"];
|
|
758
|
+
|
|
759
|
+
// Use a non-standard but semantic header name
|
|
760
|
+
headers["available-languages"] = availableLanguages.join(", ");
|
|
761
|
+
|
|
762
|
+
Object.assign(data, {
|
|
763
|
+
requestAcceptLanguageHeader,
|
|
764
|
+
availableLanguages,
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
unsupported.push({
|
|
768
|
+
type: "language",
|
|
769
|
+
message: `The server cannot produce a response in any of the languages accepted by the request: "${requestAcceptLanguageHeader}".
|
|
770
|
+
Available languages: ${availableLanguages.join(", ")}.`,
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
if (availableVersions.length) {
|
|
774
|
+
const requestAcceptVersionHeader = request.headers["accept-version"];
|
|
775
|
+
|
|
776
|
+
// Use a non-standard but semantic header name
|
|
777
|
+
headers["available-versions"] = availableVersions.join(", ");
|
|
778
|
+
|
|
779
|
+
Object.assign(data, {
|
|
780
|
+
requestAcceptVersionHeader,
|
|
781
|
+
availableLanguages,
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
unsupported.push({
|
|
785
|
+
type: "version",
|
|
786
|
+
message: `The server cannot produce a response in any of the versions accepted by the request: "${requestAcceptVersionHeader}".
|
|
787
|
+
Available versions: ${availableVersions.join(", ")}.`,
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
if (availableEncodings.length) {
|
|
791
|
+
const requestAcceptEncodingHeader = request.headers["accept-encoding"];
|
|
792
|
+
|
|
793
|
+
// Use a non-standard but semantic header name
|
|
794
|
+
headers["available-encodings"] = availableEncodings.join(", ");
|
|
795
|
+
|
|
796
|
+
Object.assign(data, {
|
|
797
|
+
requestAcceptEncodingHeader,
|
|
798
|
+
availableEncodings,
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
unsupported.push({
|
|
802
|
+
type: "encoding",
|
|
803
|
+
message: `The server cannot encode the response in any of the encodings accepted by the request: "${requestAcceptEncodingHeader}".
|
|
804
|
+
Available encodings: ${availableEncodings.join(", ")}.`,
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Special case for single negotiation failure
|
|
809
|
+
if (unsupported.length === 1) {
|
|
810
|
+
const [{ message }] = unsupported;
|
|
811
|
+
return {
|
|
812
|
+
status: 406,
|
|
813
|
+
statusText: "Not Acceptable",
|
|
814
|
+
statusMessage: message,
|
|
815
|
+
headers,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
// Handle multiple negotiation failures
|
|
819
|
+
let message = `The server cannot produce a response in a format acceptable to the client:`;
|
|
820
|
+
for (const info of unsupported) {
|
|
821
|
+
message += `\n- ${info.type} ${info.message}`;
|
|
822
|
+
}
|
|
823
|
+
return {
|
|
824
|
+
status: 406,
|
|
825
|
+
statusText: "Not Acceptable",
|
|
826
|
+
statusMessage: message,
|
|
827
|
+
headers,
|
|
828
|
+
};
|
|
829
|
+
};
|
|
830
|
+
const createMethodNotAllowedResponse = (
|
|
831
|
+
request,
|
|
832
|
+
{ allowedMethods = [] } = {},
|
|
833
|
+
) => {
|
|
834
|
+
return {
|
|
835
|
+
status: 405,
|
|
836
|
+
statusText: "Method Not Allowed",
|
|
837
|
+
statusmessage: `The HTTP method "${request.method}" is not supported for this resource.
|
|
838
|
+
Allowed methods: ${allowedMethods.join(", ")}`,
|
|
839
|
+
headers: {
|
|
840
|
+
allow: allowedMethods.join(", "),
|
|
841
|
+
},
|
|
842
|
+
};
|
|
843
|
+
};
|
|
844
|
+
const createUnsupportedMediaTypeResponse = (
|
|
845
|
+
request,
|
|
846
|
+
{ acceptedMediaTypes },
|
|
847
|
+
) => {
|
|
848
|
+
const requestContentType = request.headers["content-type"];
|
|
849
|
+
const methodSpecificHeader =
|
|
850
|
+
request.method === "POST"
|
|
851
|
+
? "accept-post"
|
|
852
|
+
: request.method === "PATCH"
|
|
853
|
+
? "accept-patch"
|
|
854
|
+
: "accept";
|
|
855
|
+
const headers = {
|
|
856
|
+
[methodSpecificHeader]: acceptedMediaTypes.join(", "),
|
|
857
|
+
};
|
|
858
|
+
const requestMethod = request.method;
|
|
859
|
+
|
|
860
|
+
return {
|
|
861
|
+
status: 415,
|
|
862
|
+
statusText: "Unsupported Media Type",
|
|
863
|
+
statusMessage: requestContentType
|
|
864
|
+
? `The media type "${requestContentType}" specified in the Content-Type header is not supported for ${requestMethod} requests to this resource.
|
|
865
|
+
Supported media types: ${acceptedMediaTypes.join(", ")}`
|
|
866
|
+
: `The Content-Type header is missing. It must be declared for ${requestMethod} requests to this resource.`,
|
|
867
|
+
headers,
|
|
868
|
+
};
|
|
869
|
+
};
|
|
870
|
+
const createRouteNotFoundResponse = (request) => {
|
|
871
|
+
return {
|
|
872
|
+
status: 404,
|
|
873
|
+
statusText: "Not Found",
|
|
874
|
+
statusMessage: `The URL ${request.resource} does not exist on this server.
|
|
875
|
+
The list of existing endpoints is available at ${routeInspectorUrl}.`,
|
|
876
|
+
headers: {},
|
|
877
|
+
};
|
|
878
|
+
};
|