@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.
Files changed (55) hide show
  1. package/README.md +79 -86
  2. package/dist/html/4xx.html +196 -0
  3. package/dist/html/500.html +17 -0
  4. package/dist/html/route_inspector.html +2435 -0
  5. package/dist/js/formidable.js +17 -0
  6. package/dist/js/vendors.js +2001 -0
  7. package/dist/js/ws.js +6863 -0
  8. package/dist/jsenv_server.js +8829 -0
  9. package/package.json +19 -9
  10. package/src/content_negotiation/pick_content_version.js +63 -0
  11. package/src/{fetch_filesystem.js → filesystem/fetch_filesystem.js} +57 -72
  12. package/src/{serve_directory.js → filesystem/serve_directory.js} +1 -2
  13. package/src/interfacing_with_node/from_node_request.js +455 -0
  14. package/src/interfacing_with_node/get_observable_value_type.js +43 -0
  15. package/src/interfacing_with_node/observable.js +91 -75
  16. package/src/interfacing_with_node/observable_from.js +34 -0
  17. package/src/interfacing_with_node/observable_from_file_handle.js +30 -0
  18. package/src/interfacing_with_node/observable_from_node_stream.js +44 -46
  19. package/src/interfacing_with_node/observable_from_node_web_readable_stream.js +32 -0
  20. package/src/interfacing_with_node/observable_from_promise.js +13 -0
  21. package/src/interfacing_with_node/write_node_response.js +80 -35
  22. package/src/internal/colorizeResponseStatus.js +2 -0
  23. package/src/internal/headers_composition.js +42 -15
  24. package/src/internal/multiple-header.js +27 -12
  25. package/src/internal/object_composition.js +2 -2
  26. package/src/internal/response_composition.js +16 -3
  27. package/src/main.js +15 -11
  28. package/src/progressive_response.js +38 -0
  29. package/src/replace_placeholder_in_html.js +12 -0
  30. package/src/request_body_handling.js +22 -0
  31. package/src/router/router.js +878 -0
  32. package/src/server_events.js +431 -0
  33. package/src/service_controller.js +79 -73
  34. package/src/services/autoreload_on_server_restart/client/autoreload_on_server_restart.js +125 -0
  35. package/src/services/autoreload_on_server_restart/jsenv_service_autoreload_on_server_restart.js +45 -0
  36. package/src/services/cors/jsenv_service_cors.js +10 -18
  37. package/src/services/default_body_4xx_5xx/client/4xx.html +65 -0
  38. package/src/services/default_body_4xx_5xx/jsenv_service_default_body_4xx_5xx.js +82 -0
  39. package/src/services/error_handler/client/500.html +17 -0
  40. package/src/services/error_handler/jsenv_service_error_handler.js +47 -28
  41. package/src/services/internal_client_files/jsenv_service_internal_client_files.js +34 -0
  42. package/src/services/open_file/jsenv_service_open_file.js +37 -0
  43. package/src/services/route_inspector/client/code_block.js +215 -0
  44. package/src/services/route_inspector/client/route_inspector.html +1363 -0
  45. package/src/services/route_inspector/jsenv_service_route_inspector.js +39 -0
  46. package/src/start_server.js +575 -607
  47. package/src/web_socket_response.js +53 -0
  48. package/src/from_fetch_response.js +0 -15
  49. package/src/interfacing_with_node/body.js +0 -81
  50. package/src/interfacing_with_node/request_factory.js +0 -125
  51. package/src/readRequestBody.js +0 -32
  52. package/src/server_timing/timing_measure.js +0 -26
  53. package/src/service_composition/path_to_regexp.js +0 -436
  54. package/src/service_composition/routing.js +0 -31
  55. 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
+ };