@jsenv/server 17.1.0 → 17.2.0

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.
@@ -47,6 +47,7 @@ export const fromNodeRequest = (
47
47
  });
48
48
 
49
49
  const headers = headersFromObject(nodeRequest.headers);
50
+ const cookies = parseRequestCookieHeader(headers["cookie"]);
50
51
  // pause the request body stream to let a chance for other parts of the code to subscribe to the stream
51
52
  // Without this the request body readable stream
52
53
  // might be closed when we'll try to attach "data" and "end" listeners to it
@@ -184,6 +185,7 @@ export const fromNodeRequest = (
184
185
  }),
185
186
  method: nodeRequest.method,
186
187
  headers,
188
+ cookies,
187
189
  body,
188
190
  buffer,
189
191
  formData,
@@ -387,6 +389,23 @@ export const readRequestBody = (request, { as }) => {
387
389
  throw new Error(`unsupported ${as}`);
388
390
  };
389
391
 
392
+ const parseRequestCookieHeader = (cookieHeader) => {
393
+ const map = new Map();
394
+ if (!cookieHeader) {
395
+ return map;
396
+ }
397
+ for (const pair of cookieHeader.split(";")) {
398
+ const eqIndex = pair.indexOf("=");
399
+ if (eqIndex === -1) {
400
+ continue;
401
+ }
402
+ const name = pair.slice(0, eqIndex).trim();
403
+ const value = decodeURIComponent(pair.slice(eqIndex + 1).trim());
404
+ map.set(name, value);
405
+ }
406
+ return map;
407
+ };
408
+
390
409
  export const applyRedirectionToRequest = (
391
410
  request,
392
411
  { resource, pathname, ...rest },
@@ -56,4 +56,16 @@ const HEADER_NAMES_COMPOSITION = {
56
56
  "server-timing": composeTwoCommaSeparatedValues,
57
57
  // 'content-type', // https://github.com/ninenines/cowboy/issues/1230
58
58
  "vary": composeTwoCommaSeparatedValues,
59
+ // set-cookie is special: multiple values must be kept as an array
60
+ "set-cookie": (leftValue, rightValue) => {
61
+ if (!leftValue) {
62
+ return rightValue;
63
+ }
64
+ if (!rightValue) {
65
+ return leftValue;
66
+ }
67
+ const left = Array.isArray(leftValue) ? leftValue : [leftValue];
68
+ const right = Array.isArray(rightValue) ? rightValue : [rightValue];
69
+ return [...left, ...right];
70
+ },
59
71
  };
@@ -0,0 +1,77 @@
1
+ // https://bun.sh/docs/api/cookie
2
+
3
+ export const COOKIE = {
4
+ from: (name, value, options = {}) => {
5
+ return { name, value, options };
6
+ },
7
+ parse: (cookieString) => {
8
+ const parts = cookieString.split("; ");
9
+ const [nameValue, ...attributes] = parts;
10
+ const [name, value] = nameValue.split("=");
11
+ const options = {};
12
+ for (const attr of attributes) {
13
+ const [key, val] = attr.split("=");
14
+ if (key === "expires") {
15
+ options.expires = new Date(val);
16
+ continue;
17
+ }
18
+ if (key === "path") {
19
+ options.path = val;
20
+ continue;
21
+ }
22
+ if (key === "domain") {
23
+ options.domain = val;
24
+ continue;
25
+ }
26
+ if (key === "secure") {
27
+ options.secure = true;
28
+ continue;
29
+ }
30
+ if (key === "httponly") {
31
+ options.httpOnly = true;
32
+ continue;
33
+ }
34
+ }
35
+
36
+ return { name, value, options };
37
+ },
38
+ stringify: ({ name, value, options }) => {
39
+ let str = `${name}=${value}`;
40
+ if (options.expires) {
41
+ str += `; Expires=${options.expires.toUTCString()}`;
42
+ }
43
+ if (options.maxAge !== undefined) {
44
+ str += `; Max-Age=${options.maxAge}`;
45
+ }
46
+ if (options.path) {
47
+ str += `; Path=${options.path}`;
48
+ }
49
+ if (options.domain) {
50
+ str += `; Domain=${options.domain}`;
51
+ }
52
+ if (options.sameSite) {
53
+ str += `; SameSite=${options.sameSite}`;
54
+ }
55
+ if (options.secure) {
56
+ str += "; Secure";
57
+ }
58
+ if (options.httpOnly) {
59
+ str += "; HttpOnly";
60
+ }
61
+ return str;
62
+ },
63
+ toSetCookieHeaders: (cookies) => {
64
+ const headers = {};
65
+ for (const cookie of cookies) {
66
+ const value = COOKIE.stringify(cookie);
67
+ if (headers["set-cookie"] === undefined) {
68
+ headers["set-cookie"] = value;
69
+ } else if (Array.isArray(headers["set-cookie"])) {
70
+ headers["set-cookie"].push(value);
71
+ } else {
72
+ headers["set-cookie"] = [headers["set-cookie"], value];
73
+ }
74
+ }
75
+ return headers;
76
+ },
77
+ };
@@ -0,0 +1,30 @@
1
+ import { COOKIE } from "./cookie.js";
2
+
3
+ export const serverPluginResponseCookies = () => {
4
+ return {
5
+ name: "jsenv:response_cookies",
6
+ augmentRouteFetchSecondArg: (request, fetchSecondArg) => {
7
+ const responseCookies = {
8
+ set: (name, value, options = {}) => {
9
+ fetchSecondArg.injectResponseHeader(
10
+ "set-cookie",
11
+ COOKIE.stringify(COOKIE.from(name, value, options)),
12
+ );
13
+ },
14
+ delete: (name, options = {}) => {
15
+ fetchSecondArg.injectResponseHeader(
16
+ "set-cookie",
17
+ COOKIE.stringify(
18
+ COOKIE.from(name, "", {
19
+ ...options,
20
+ expires: new Date(0),
21
+ }),
22
+ ),
23
+ );
24
+ },
25
+ };
26
+
27
+ return { responseCookies };
28
+ },
29
+ };
30
+ };
@@ -75,17 +75,32 @@ const generateAccessControlHeaders = ({
75
75
  } = {}) => {
76
76
  const vary = [];
77
77
 
78
- const allowedOriginArray = [...accessControlAllowedOrigins];
79
- if (accessControlAllowRequestOrigin) {
80
- if ("origin" in headers && headers.origin !== "null") {
81
- allowedOriginArray.push(headers.origin);
78
+ // Access-Control-Allow-Origin must be a single value (not a list).
79
+ // We reflect back the request's origin if it is in the allowed list.
80
+ // If no origin matches we fall back to "*" (only when not using credentials).
81
+ let allowOrigin = null;
82
+
83
+ const requestOrigin =
84
+ "origin" in headers && headers.origin !== "null"
85
+ ? headers.origin
86
+ : "referer" in headers
87
+ ? new URL(headers.referer).origin
88
+ : null;
89
+
90
+ if (requestOrigin) {
91
+ if (accessControlAllowedOrigins.includes(requestOrigin)) {
92
+ allowOrigin = requestOrigin;
93
+ vary.push("origin");
94
+ } else if (accessControlAllowRequestOrigin) {
95
+ allowOrigin = requestOrigin;
82
96
  vary.push("origin");
83
- } else if ("referer" in headers) {
84
- allowedOriginArray.push(new URL(headers.referer).origin);
85
- vary.push("referer");
86
- } else {
87
- allowedOriginArray.push("*");
88
97
  }
98
+ } else if (accessControlAllowRequestOrigin) {
99
+ allowOrigin = "*";
100
+ }
101
+
102
+ if (allowOrigin === null) {
103
+ allowOrigin = accessControlAllowedOrigins[0] ?? "*";
89
104
  }
90
105
 
91
106
  const allowedMethodArray = [...accessControlAllowedMethods];
@@ -119,16 +134,14 @@ const generateAccessControlHeaders = ({
119
134
  }
120
135
 
121
136
  return {
122
- "access-control-allow-origin": allowedOriginArray.join(", "),
137
+ "access-control-allow-origin": allowOrigin,
123
138
  "access-control-allow-methods": allowedMethodArray.join(", "),
124
139
  "access-control-allow-headers": allowedHeaderArray.join(", "),
125
140
  ...(accessControlAllowCredentials
126
141
  ? { "access-control-allow-credentials": true }
127
142
  : {}),
128
143
  "access-control-max-age": accessControlMaxAge,
129
- ...(timingAllowOrigin
130
- ? { "timing-allow-origin": allowedOriginArray.join(", ") }
131
- : {}),
144
+ ...(timingAllowOrigin ? { "timing-allow-origin": allowOrigin } : {}),
132
145
  ...(vary.length ? { vary: vary.join(", ") } : {}),
133
146
  };
134
147
  };
@@ -113,7 +113,9 @@
113
113
  }
114
114
  }
115
115
 
116
- customElements.define("clipboard-copy", ClipboardCopy);
116
+ if (!customElements.get("clipboard-copy")) {
117
+ customElements.define("clipboard-copy", ClipboardCopy);
118
+ }
117
119
 
118
120
  const addToClipboard = async (text) => {
119
121
  const type = "text/plain";
@@ -42,6 +42,13 @@
42
42
  --border-color: #e0e0e0;
43
43
  --primary-color: #1976d2;
44
44
  --secondary-color: #f5f5f5;
45
+ --forbidden-bg-color: #fff8f0;
46
+ --forbidden-border-color: #cc8800;
47
+ --forbidden-badge-bg: #fff3cc;
48
+ --forbidden-badge-color: #7a5000;
49
+ --admin-badge-bg: #f0f4ff;
50
+ --admin-badge-border-color: #5c7cfa;
51
+ --admin-badge-color: #2a4bbf;
45
52
  }
46
53
  :root[data-theme="dark"] {
47
54
  --background-color: #121212;
@@ -49,6 +56,13 @@
49
56
  --border-color: #333;
50
57
  --primary-color: #4f9eff;
51
58
  --secondary-color: #1e1e1e;
59
+ --forbidden-bg-color: #1e1500;
60
+ --forbidden-border-color: #8a6000;
61
+ --forbidden-badge-bg: #3a2800;
62
+ --forbidden-badge-color: #f0c060;
63
+ --admin-badge-bg: #0d1a3a;
64
+ --admin-badge-border-color: #3b5bdb;
65
+ --admin-badge-color: #91a7ff;
52
66
  }
53
67
 
54
68
  :root[data-theme="dark"] a {
@@ -467,6 +481,42 @@
467
481
  --method-color: var(--method-delete-color);
468
482
  }
469
483
 
484
+ .route_details[data-forbidden] {
485
+ background: var(--forbidden-bg-color);
486
+ border-color: var(--forbidden-border-color);
487
+ opacity: 0.85;
488
+ }
489
+ .route_details[data-forbidden] .route_method {
490
+ opacity: 0.7;
491
+ filter: grayscale(60%);
492
+ }
493
+ .route_forbidden_badge {
494
+ margin-right: 6px;
495
+ padding: 3px 8px;
496
+ color: var(--forbidden-badge-color);
497
+ font-weight: 600;
498
+ font-size: 12px;
499
+ white-space: nowrap;
500
+ background: var(--forbidden-badge-bg);
501
+ border: 1px solid var(--forbidden-border-color);
502
+ border-radius: 4px;
503
+ }
504
+ .route_admin_badge {
505
+ margin-right: 6px;
506
+ padding: 3px 8px;
507
+ color: var(--admin-badge-color);
508
+ font-weight: 600;
509
+ font-size: 12px;
510
+ white-space: nowrap;
511
+ background: var(--admin-badge-bg);
512
+ border: 1px solid var(--admin-badge-border-color);
513
+ border-radius: 4px;
514
+ }
515
+ .route_forbidden_content {
516
+ color: var(--forbidden-badge-color);
517
+ font-style: italic;
518
+ }
519
+
470
520
  .route_owner_name {
471
521
  padding: 4px 8px;
472
522
  color: #666;
@@ -1388,6 +1438,8 @@
1388
1438
  websocket,
1389
1439
  clientCodeExample,
1390
1440
  declarationSource,
1441
+ forbidden,
1442
+ visibleBecauseAdmin,
1391
1443
  }) => {
1392
1444
  const [selectedMethod] = useUrlStringParam("method");
1393
1445
  const [selectedPackageName] = usePackageUrlStringParam();
@@ -1400,11 +1452,16 @@
1400
1452
  return null;
1401
1453
  }
1402
1454
 
1403
- return html`<details class="route_details" data-method="${method}">
1455
+ return html`<details
1456
+ class="route_details"
1457
+ data-method="${method}"
1458
+ data-forbidden="${forbidden || undefined}"
1459
+ data-admin-visible="${visibleBecauseAdmin || undefined}"
1460
+ >
1404
1461
  <summary class="route_summary_container">
1405
1462
  <span class="route_method">${method}</span>
1406
1463
  <p class="route_resource">${resource}</p>
1407
- ${method === "GET"
1464
+ ${method === "GET" && !forbidden
1408
1465
  ? html`<a
1409
1466
  style="display:inline-flex; margin-left: 5px;"
1410
1467
  href="${resource}"
@@ -1412,25 +1469,45 @@
1412
1469
  /></a>`
1413
1470
  : null}
1414
1471
  <span style="flex:1"></span>
1472
+ ${visibleBecauseAdmin
1473
+ ? html`<span
1474
+ class="route_admin_badge"
1475
+ title="This route is only visible because the server was started with canExposeSensitiveData"
1476
+ >&#128274; admin only</span
1477
+ >`
1478
+ : null}
1479
+ ${forbidden && !visibleBecauseAdmin
1480
+ ? html`<span class="route_forbidden_badge"
1481
+ >&#128274; access denied</span
1482
+ >`
1483
+ : null}
1415
1484
  <${RouteBadge} meta="${meta}" />
1416
1485
  <span class="summary_marker">
1417
1486
  <${ArrowDown} />
1418
1487
  </span>
1419
1488
  </summary>
1420
- <${RouteDetailsContent}
1421
- method="${method}"
1422
- resource="${resource}"
1423
- description="${description}"
1424
- availableMediaTypes="${availableMediaTypes}"
1425
- availableLanguages="${availableLanguages}"
1426
- availableVersions="${availableVersions}"
1427
- availableEncodings="${availableEncodings}"
1428
- acceptedMediaTypes="${acceptedMediaTypes}"
1429
- websocket="${websocket}"
1430
- clientCodeExample="${clientCodeExample}"
1431
- declarationSource="${declarationSource}"
1432
- meta="${meta}"
1433
- />
1489
+ ${!forbidden
1490
+ ? html`<${RouteDetailsContent}
1491
+ method="${method}"
1492
+ resource="${resource}"
1493
+ description="${description}"
1494
+ availableMediaTypes="${availableMediaTypes}"
1495
+ availableLanguages="${availableLanguages}"
1496
+ availableVersions="${availableVersions}"
1497
+ availableEncodings="${availableEncodings}"
1498
+ acceptedMediaTypes="${acceptedMediaTypes}"
1499
+ websocket="${websocket}"
1500
+ clientCodeExample="${clientCodeExample}"
1501
+ declarationSource="${declarationSource}"
1502
+ meta="${meta}"
1503
+ />`
1504
+ : html`<div class="route_details_content route_forbidden_content">
1505
+ <p>
1506
+ ${visibleBecauseAdmin
1507
+ ? "This route is only visible because the server was started with canExposeSensitiveData. Access is denied with the current permissions."
1508
+ : "You do not have the required permissions to access this route."}
1509
+ </p>
1510
+ </div>`}
1434
1511
  </details>`;
1435
1512
  };
1436
1513
 
@@ -1561,26 +1638,26 @@
1561
1638
  const websocketScheme =
1562
1639
  self.location.protocol === "https:" ? "wss" : "ws";
1563
1640
  const websocketOrigin = `${websocketScheme}://${self.location.host}`;
1564
- jsSource = /*js */ `
1565
- const websocket = new WebSocket("${websocketOrigin}${resource}");
1566
- websocket.onerror = () => {
1567
- console.error("Failed to connect to websocket endpoint");
1568
- };
1569
- websocket.onopen = () => {
1570
- console.info("Connected to websocket endpoint")
1571
- };
1572
- websocket.onmessage = (event) => {
1573
- console.log("Received message", event.data);
1574
- };
1575
- `;
1641
+ jsSource = `
1642
+ const websocket = new WebSocket("${websocketOrigin}${resource}");
1643
+ websocket.onerror = () => {
1644
+ console.error("Failed to connect to websocket endpoint");
1645
+ };
1646
+ websocket.onopen = () => {
1647
+ console.info("Connected to websocket endpoint");
1648
+ };
1649
+ websocket.onmessage = (event) => {
1650
+ console.log("Received message", event.data);
1651
+ };
1652
+ `;
1576
1653
  } else {
1577
1654
  let fetchLine;
1578
1655
  if (method === "GET") {
1579
1656
  fetchLine = `const response = await window.fetch("${resource}");`;
1580
1657
  } else {
1581
1658
  fetchLine = `const response = await window.fetch("${resource}", {
1582
- method: "${method}",
1583
- }`;
1659
+ method: "${method}",
1660
+ }`;
1584
1661
  }
1585
1662
  let readResponseLine;
1586
1663
  if (availableMediaTypes.includes("application/json")) {
@@ -1589,10 +1666,10 @@
1589
1666
  readResponseLine = `const text = await response.text();`;
1590
1667
  }
1591
1668
 
1592
- jsSource = /*js */ `
1593
- ${fetchLine}
1594
- ${readResponseLine}
1595
- `;
1669
+ jsSource = `
1670
+ ${fetchLine};
1671
+ ${readResponseLine};
1672
+ `;
1596
1673
  }
1597
1674
 
1598
1675
  return html`<code-block lang="js" data-escaped data-copy-button
@@ -28,9 +28,10 @@ export const serverPluginRouteInspector = ({ canExposeSensitiveData }) => {
28
28
  availableMediaTypes: ["application/json"],
29
29
  description: "Get the routes available on this server in JSON.",
30
30
  declarationSource: import.meta.url,
31
- fetch: (request, helpers) => {
32
- const routeJSON = helpers.router.inspect({
31
+ fetch: async (request, helpers) => {
32
+ const routeJSON = await helpers.router.inspect({
33
33
  canExposeSensitiveData,
34
+ fetchSecondArg: helpers,
34
35
  });
35
36
  return Response.json(routeJSON);
36
37
  },
@@ -9,6 +9,7 @@ export const createServerPluginsController = async (serverPlugins) => {
9
9
  serverListening: { type: "hook" },
10
10
  redirectRequest: { type: "hook" },
11
11
  augmentRouteFetchSecondArg: { type: "hook" },
12
+ grantPermissions: { type: "hook" },
12
13
  handleError: { type: "hook" },
13
14
  onResponsePush: { type: "hook" },
14
15
  injectResponseProperties: { type: "hook" },
@@ -297,6 +297,42 @@ export const createPluginsController = async ({
297
297
  callHooksUntil,
298
298
  callAsyncHooks,
299
299
  callAsyncHooksUntil,
300
+ // callAsyncHooksWhile(hookName, info, callback)
301
+ // Calls hooks one by one, passing each return value to callback.
302
+ // Stops as soon as callback returns false.
303
+ callAsyncHooksWhile: async (hookName, info, callback) => {
304
+ const hookSet = hookSetMap.get(hookName);
305
+ if (!hookSet) {
306
+ return;
307
+ }
308
+ for (const hook of hookSet) {
309
+ const returnValue = await callAsyncHook(hook, info);
310
+ const shouldContinue = await callback(returnValue);
311
+ if (!shouldContinue) {
312
+ return;
313
+ }
314
+ }
315
+ },
316
+
317
+ // createAsyncHookIterator(hookName, info)
318
+ // Returns an async function that calls the next hook on each invocation.
319
+ // Returns { done: true } when all hooks have been called.
320
+ // Allows callers to maintain a shared cursor across multiple consumers.
321
+ createAsyncHookIterator: (hookName, info) => {
322
+ const hookSet = hookSetMap.get(hookName);
323
+ if (!hookSet) {
324
+ return async () => ({ done: true, value: undefined });
325
+ }
326
+ const iterator = hookSet.values();
327
+ return async () => {
328
+ const { done, value: hook } = iterator.next();
329
+ if (done) {
330
+ return { done: true, value: undefined };
331
+ }
332
+ const value = await callAsyncHook(hook, info);
333
+ return { done: false, value };
334
+ };
335
+ },
300
336
 
301
337
  getPluginMeta: (pluginName) => meta[pluginName],
302
338
  getMeta: () => meta,