@jsenv/server 17.1.1 → 17.3.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.
@@ -24,6 +24,7 @@ import { createPolyglotServer } from "./internal/server-polyglot.js";
24
24
  import { trackServerPendingConnections } from "./internal/trackServerPendingConnections.js";
25
25
  import { trackServerPendingRequests } from "./internal/trackServerPendingRequests.js";
26
26
  import { serverPluginAutoreloadOnRestart } from "./plugins/autoreload_on_server_restart/server_plugin_autoreload_on_server_restart.js";
27
+ import { serverPluginResponseCookies } from "./plugins/cookies/server_plugin_response_cookies.js";
27
28
  import { serverPluginDefaultBody4xx5xx } from "./plugins/default_body_4xx_5xx/server_plugin_default_body_4xx_5xx.js";
28
29
  import { serverPluginInternalClientFiles } from "./plugins/internal_client_files/server_plugin_internal_client_files.js";
29
30
  import { serverPluginOpenFile } from "./plugins/open_file/server_plugin_open_file.js";
@@ -50,6 +51,68 @@ const TIMING_NOOP = () => {
50
51
  return { end: () => {} };
51
52
  };
52
53
 
54
+ const permissionsSatisfy = (permissionsSet, permissionsRequired) => {
55
+ for (const p of permissionsRequired) {
56
+ if (!permissionsSet.has(p)) {
57
+ return false;
58
+ }
59
+ }
60
+ return true;
61
+ };
62
+
63
+ /**
64
+ * Starts an HTTP (or HTTPS/HTTP2) server.
65
+ *
66
+ * @param {Object} params
67
+ *
68
+ * @param {Array<Object>} [params.routes=[]] - Route definitions for the server.
69
+ * Each route is an object with the following properties:
70
+ * - `endpoint` {string} — Required. A string like `"GET /users/:id"` combining the HTTP method
71
+ * and the resource path. Use `"*"` as the method to match all methods, and URL patterns
72
+ * such as `:param` for named segments or `*` for wildcards.
73
+ * - `fetch` {Function} — Required. `(request, helpers) => response`. Must return a `Response`,
74
+ * a response-like object `{ status, headers, body }`, or `null`/`undefined` to skip the route.
75
+ * - `description` {string} — Optional human-readable description shown in the route inspector.
76
+ * - `permissionsRequired` {Array<string>} — Optional. Permissions the client must hold to
77
+ * access the route. An empty array means anyone can access. When omitted the route is hidden
78
+ * by default (404 for everyone).
79
+ * - `permissionsToSee` {Array<string>} — Optional. Permissions needed to know the route exists
80
+ * (403 instead of 404 when access is denied). An empty array means the route is visible to
81
+ * everyone even when access is denied.
82
+ * - `availableMediaTypes` {Array<string>} — Content-types this route can produce (drives
83
+ * `Accept` content negotiation).
84
+ * - `availableLanguages` {Array<string>} — Languages this route can respond with.
85
+ * - `availableEncodings` {Array<string>} — Encodings this route supports.
86
+ * - `acceptedMediaTypes` {Array<string>} — Content-types accepted for request bodies
87
+ * (POST/PATCH/PUT).
88
+ * - `clientCodeExample` {string|Function} — Optional code snippet displayed in the route
89
+ * inspector as a usage example.
90
+ * - `declarationSource` {string} — Optional file URL of where the route is declared, shown
91
+ * in the route inspector when `canExposeSensitiveData` is enabled.
92
+ * - `headers` {Object} — Optional header pattern that must match for the route to be selected.
93
+ *
94
+ * @param {Array<Object>} [params.plugins=[]] - Server plugins that extend behaviour (hooks such
95
+ * as `grantPermissions`, `redirectRequest`, `handleError`, etc.). See plugin documentation for
96
+ * the exact shape; plugins are not described here in detail.
97
+ *
98
+ * @param {number} [params.port=0] - Port to listen on. Defaults to `0` which lets the OS assign
99
+ * a random available port — useful in tests to avoid port conflicts. A fixed port such as
100
+ * `3000` can be passed for a predictable address.
101
+ *
102
+ * @param {boolean} [params.acceptAnyIp=false] - When `true` the server binds to all network
103
+ * interfaces (`0.0.0.0` / `::`), making it reachable from other machines on the network.
104
+ * When `false` (default) it only listens on the configured `hostname`.
105
+ *
106
+ * @param {boolean} [params.canExposeSensitiveData=false] - Unlocks developer-facing features that
107
+ * should never be enabled in production:
108
+ * - Declaration source links in the route inspector (shows where each route is defined).
109
+ * - The `/.internal/open_file` endpoint that can open files on the server machine.
110
+ * - Auto-reload client notification when the server restarts.
111
+ * - All routes are visible in the route inspector regardless of permission configuration.
112
+ * Set to `true` during local development for a better DX.
113
+ *
114
+ * @returns {Promise<Object>} Resolves to the running server object with `{ origin, port, stop, … }`.
115
+ */
53
116
  export const startServer = async ({
54
117
  signal = new AbortController().signal,
55
118
  logLevel,
@@ -160,6 +223,7 @@ export const startServer = async ({
160
223
 
161
224
  plugins = [
162
225
  ...(canExposeSensitiveData ? [serverPluginOpenFile()] : []),
226
+ serverPluginResponseCookies(),
163
227
  serverPluginDefaultBody4xx5xx(),
164
228
  serverPluginRouteInspector({ canExposeSensitiveData }),
165
229
  ...(canExposeSensitiveData ? [serverPluginInternalClientFiles()] : []),
@@ -344,6 +408,7 @@ export const startServer = async ({
344
408
  stopCallbackSet.clear();
345
409
  await Promise.all(promises);
346
410
  serverPluginsController.callHooks("serverStopped", { reason });
411
+ await serverPluginsController.destroyAllPlugins();
347
412
  status = "stopped";
348
413
  stoppedResolve(reason);
349
414
  });
@@ -571,6 +636,48 @@ export const startServer = async ({
571
636
  }
572
637
  },
573
638
  );
639
+ // Build permission helpers, lazily per request.
640
+ // A single shared iterator ensures each plugin is called at most once.
641
+ // hasPermissions stops early once the rule is satisfied;
642
+ // getAllPermissions drains all remaining plugins.
643
+ const permissionsSet = new Set();
644
+ const nextPermissionsHook =
645
+ serverPluginsController.createAsyncHookIterator(
646
+ "grantPermissions",
647
+ request,
648
+ );
649
+ const drainPermissionsUntil = async (permissionsRequired) => {
650
+ for (;;) {
651
+ if (
652
+ permissionsRequired !== undefined &&
653
+ permissionsSatisfy(permissionsSet, permissionsRequired)
654
+ ) {
655
+ return true;
656
+ }
657
+ const { done, value } = await nextPermissionsHook();
658
+ if (done) {
659
+ break;
660
+ }
661
+ if (Array.isArray(value)) {
662
+ for (const p of value) {
663
+ permissionsSet.add(p);
664
+ }
665
+ }
666
+ }
667
+ return false;
668
+ };
669
+ const getAllPermissions = async () => {
670
+ await drainPermissionsUntil(undefined);
671
+ return permissionsSet;
672
+ };
673
+ const hasPermissions = async (permissionsRequired) => {
674
+ if (permissionsRequired.length === 0) {
675
+ return true;
676
+ }
677
+ return drainPermissionsUntil(permissionsRequired);
678
+ };
679
+ fetchSecondArg.getAllPermissions = getAllPermissions;
680
+ fetchSecondArg.hasPermissions = hasPermissions;
574
681
  const routerResponseProperties = await router.match(
575
682
  request,
576
683
  fetchSecondArg,
@@ -1,42 +0,0 @@
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
-
19
- if (key === "path") {
20
- options.path = val;
21
- continue;
22
- }
23
- if (key === "domain") {
24
- options.domain = val;
25
- continue;
26
- }
27
- if (key === "secure") {
28
- options.secure = true;
29
- continue;
30
- }
31
- if (key === "httponly") {
32
- options.httpOnly = true;
33
- continue;
34
- }
35
- }
36
-
37
- return { name, value, options };
38
- },
39
- stringify: ({ name, value, options }) => {
40
- return `${name}=${value}${options.expires ? `; Expires=${options.expires.toUTCString()}` : ""}${options.path ? `; Path=${options.path}` : ""}${options.domain ? `; Domain=${options.domain}` : ""}${options.secure ? "; Secure" : ""}${options.httpOnly ? "; HttpOnly" : ""}`;
41
- },
42
- };
@@ -1,18 +0,0 @@
1
- import { COOKIE } from "./cookie.js";
2
-
3
- export const createCookieMap = (setCookieString) => {
4
- const map = new Map();
5
-
6
- if (setCookieString) {
7
- const cookies = setCookieString.split(", ");
8
- for (const cookie of cookies) {
9
- const parsedCookie = COOKIE.parse(cookie);
10
- map.set(parsedCookie.name, parsedCookie);
11
- }
12
- }
13
-
14
- return {
15
- map,
16
- toSetCookieHeaders: () => {},
17
- };
18
- };