@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.
@@ -1321,6 +1321,7 @@ const fromNodeRequest = (
1321
1321
  });
1322
1322
 
1323
1323
  const headers = headersFromObject(nodeRequest.headers);
1324
+ const cookies = parseRequestCookieHeader(headers["cookie"]);
1324
1325
  // pause the request body stream to let a chance for other parts of the code to subscribe to the stream
1325
1326
  // Without this the request body readable stream
1326
1327
  // might be closed when we'll try to attach "data" and "end" listeners to it
@@ -1458,6 +1459,7 @@ const fromNodeRequest = (
1458
1459
  }),
1459
1460
  method: nodeRequest.method,
1460
1461
  headers,
1462
+ cookies,
1461
1463
  body,
1462
1464
  buffer,
1463
1465
  formData,
@@ -1648,6 +1650,23 @@ const readBody = (body, { as }) => {
1648
1650
  });
1649
1651
  };
1650
1652
 
1653
+ const parseRequestCookieHeader = (cookieHeader) => {
1654
+ const map = new Map();
1655
+ if (!cookieHeader) {
1656
+ return map;
1657
+ }
1658
+ for (const pair of cookieHeader.split(";")) {
1659
+ const eqIndex = pair.indexOf("=");
1660
+ if (eqIndex === -1) {
1661
+ continue;
1662
+ }
1663
+ const name = pair.slice(0, eqIndex).trim();
1664
+ const value = decodeURIComponent(pair.slice(eqIndex + 1).trim());
1665
+ map.set(name, value);
1666
+ }
1667
+ return map;
1668
+ };
1669
+
1651
1670
  const applyRedirectionToRequest = (
1652
1671
  request,
1653
1672
  { resource, pathname, ...rest },
@@ -2307,6 +2326,18 @@ const HEADER_NAMES_COMPOSITION = {
2307
2326
  "server-timing": composeTwoCommaSeparatedValues,
2308
2327
  // 'content-type', // https://github.com/ninenines/cowboy/issues/1230
2309
2328
  "vary": composeTwoCommaSeparatedValues,
2329
+ // set-cookie is special: multiple values must be kept as an array
2330
+ "set-cookie": (leftValue, rightValue) => {
2331
+ if (!leftValue) {
2332
+ return rightValue;
2333
+ }
2334
+ if (!rightValue) {
2335
+ return leftValue;
2336
+ }
2337
+ const left = Array.isArray(leftValue) ? leftValue : [leftValue];
2338
+ const right = Array.isArray(rightValue) ? rightValue : [rightValue];
2339
+ return [...left, ...right];
2340
+ },
2310
2341
  };
2311
2342
 
2312
2343
  const listen = async ({
@@ -3380,6 +3411,113 @@ This endpoint exists mostly to demo eventsource as there is already the websocke
3380
3411
  };
3381
3412
  };
3382
3413
 
3414
+ // https://bun.sh/docs/api/cookie
3415
+
3416
+ const COOKIE = {
3417
+ from: (name, value, options = {}) => {
3418
+ return { name, value, options };
3419
+ },
3420
+ parse: (cookieString) => {
3421
+ const parts = cookieString.split("; ");
3422
+ const [nameValue, ...attributes] = parts;
3423
+ const [name, value] = nameValue.split("=");
3424
+ const options = {};
3425
+ for (const attr of attributes) {
3426
+ const [key, val] = attr.split("=");
3427
+ if (key === "expires") {
3428
+ options.expires = new Date(val);
3429
+ continue;
3430
+ }
3431
+ if (key === "path") {
3432
+ options.path = val;
3433
+ continue;
3434
+ }
3435
+ if (key === "domain") {
3436
+ options.domain = val;
3437
+ continue;
3438
+ }
3439
+ if (key === "secure") {
3440
+ options.secure = true;
3441
+ continue;
3442
+ }
3443
+ if (key === "httponly") {
3444
+ options.httpOnly = true;
3445
+ continue;
3446
+ }
3447
+ }
3448
+
3449
+ return { name, value, options };
3450
+ },
3451
+ stringify: ({ name, value, options }) => {
3452
+ let str = `${name}=${value}`;
3453
+ if (options.expires) {
3454
+ str += `; Expires=${options.expires.toUTCString()}`;
3455
+ }
3456
+ if (options.maxAge !== undefined) {
3457
+ str += `; Max-Age=${options.maxAge}`;
3458
+ }
3459
+ if (options.path) {
3460
+ str += `; Path=${options.path}`;
3461
+ }
3462
+ if (options.domain) {
3463
+ str += `; Domain=${options.domain}`;
3464
+ }
3465
+ if (options.sameSite) {
3466
+ str += `; SameSite=${options.sameSite}`;
3467
+ }
3468
+ if (options.secure) {
3469
+ str += "; Secure";
3470
+ }
3471
+ if (options.httpOnly) {
3472
+ str += "; HttpOnly";
3473
+ }
3474
+ return str;
3475
+ },
3476
+ toSetCookieHeaders: (cookies) => {
3477
+ const headers = {};
3478
+ for (const cookie of cookies) {
3479
+ const value = COOKIE.stringify(cookie);
3480
+ if (headers["set-cookie"] === undefined) {
3481
+ headers["set-cookie"] = value;
3482
+ } else if (Array.isArray(headers["set-cookie"])) {
3483
+ headers["set-cookie"].push(value);
3484
+ } else {
3485
+ headers["set-cookie"] = [headers["set-cookie"], value];
3486
+ }
3487
+ }
3488
+ return headers;
3489
+ },
3490
+ };
3491
+
3492
+ const serverPluginResponseCookies = () => {
3493
+ return {
3494
+ name: "jsenv:response_cookies",
3495
+ augmentRouteFetchSecondArg: (request, fetchSecondArg) => {
3496
+ const responseCookies = {
3497
+ set: (name, value, options = {}) => {
3498
+ fetchSecondArg.injectResponseHeader(
3499
+ "set-cookie",
3500
+ COOKIE.stringify(COOKIE.from(name, value, options)),
3501
+ );
3502
+ },
3503
+ delete: (name, options = {}) => {
3504
+ fetchSecondArg.injectResponseHeader(
3505
+ "set-cookie",
3506
+ COOKIE.stringify(
3507
+ COOKIE.from(name, "", {
3508
+ ...options,
3509
+ expires: new Date(0),
3510
+ }),
3511
+ ),
3512
+ );
3513
+ },
3514
+ };
3515
+
3516
+ return { responseCookies };
3517
+ },
3518
+ };
3519
+ };
3520
+
3383
3521
  const pickAcceptedContent = ({
3384
3522
  availables,
3385
3523
  accepteds,
@@ -3888,9 +4026,10 @@ const serverPluginRouteInspector = ({ canExposeSensitiveData }) => {
3888
4026
  availableMediaTypes: ["application/json"],
3889
4027
  description: "Get the routes available on this server in JSON.",
3890
4028
  declarationSource: import.meta.url,
3891
- fetch: (request, helpers) => {
3892
- const routeJSON = helpers.router.inspect({
4029
+ fetch: async (request, helpers) => {
4030
+ const routeJSON = await helpers.router.inspect({
3893
4031
  canExposeSensitiveData,
4032
+ fetchSecondArg: helpers,
3894
4033
  });
3895
4034
  return Response.json(routeJSON);
3896
4035
  },
@@ -4196,12 +4335,55 @@ const createPluginsController = async ({
4196
4335
  callHooksUntil,
4197
4336
  callAsyncHooks,
4198
4337
  callAsyncHooksUntil,
4338
+ // callAsyncHooksWhile(hookName, info, callback)
4339
+ // Calls hooks one by one, passing each return value to callback.
4340
+ // Stops as soon as callback returns false.
4341
+ callAsyncHooksWhile: async (hookName, info, callback) => {
4342
+ const hookSet = hookSetMap.get(hookName);
4343
+ if (!hookSet) {
4344
+ return;
4345
+ }
4346
+ for (const hook of hookSet) {
4347
+ const returnValue = await callAsyncHook(hook, info);
4348
+ const shouldContinue = await callback(returnValue);
4349
+ if (!shouldContinue) {
4350
+ return;
4351
+ }
4352
+ }
4353
+ },
4354
+
4355
+ // createAsyncHookIterator(hookName, info)
4356
+ // Returns an async function that calls the next hook on each invocation.
4357
+ // Returns { done: true } when all hooks have been called.
4358
+ // Allows callers to maintain a shared cursor across multiple consumers.
4359
+ createAsyncHookIterator: (hookName, info) => {
4360
+ const hookSet = hookSetMap.get(hookName);
4361
+ if (!hookSet) {
4362
+ return async () => ({ done: true, value: undefined });
4363
+ }
4364
+ const iterator = hookSet.values();
4365
+ return async () => {
4366
+ const { done, value: hook } = iterator.next();
4367
+ if (done) {
4368
+ return { done: true, value: undefined };
4369
+ }
4370
+ const value = await callAsyncHook(hook, info);
4371
+ return { done: false, value };
4372
+ };
4373
+ },
4199
4374
 
4200
4375
  getPluginMeta: (pluginName) => meta[pluginName],
4201
4376
  getMeta: () => meta,
4202
4377
  getLastPluginUsed: () => lastPluginUsed,
4203
4378
  getCurrentPlugin: () => currentPlugin,
4204
4379
  getCurrentHookName: () => currentHookName,
4380
+ destroyAllPlugins: async () => {
4381
+ for (const plugin of activePlugins) {
4382
+ if (plugin.destroy) {
4383
+ await plugin.destroy();
4384
+ }
4385
+ }
4386
+ },
4205
4387
  };
4206
4388
  };
4207
4389
 
@@ -4248,6 +4430,7 @@ const createServerPluginsController = async (serverPlugins) => {
4248
4430
  serverListening: { type: "hook" },
4249
4431
  redirectRequest: { type: "hook" },
4250
4432
  augmentRouteFetchSecondArg: { type: "hook" },
4433
+ grantPermissions: { type: "hook" },
4251
4434
  handleError: { type: "hook" },
4252
4435
  onResponsePush: { type: "hook" },
4253
4436
  injectResponseProperties: { type: "hook" },
@@ -5675,6 +5858,7 @@ const createRouter = (
5675
5858
  ) => {
5676
5859
  const logger = createLogger({ logLevel });
5677
5860
  const routeSet = new Set();
5861
+ let someRouteHasPermissions = false;
5678
5862
 
5679
5863
  const constructAvailableEndpoints = () => {
5680
5864
  // TODO: memoize
@@ -5816,6 +6000,12 @@ const createRouter = (
5816
6000
  for (const routeDescription of routeDescriptionArray) {
5817
6001
  const route = createRoute(routeDescription);
5818
6002
  routeSet.add(route);
6003
+ if (
6004
+ route.permissionsRequired !== undefined ||
6005
+ route.permissionsToSee !== undefined
6006
+ ) {
6007
+ someRouteHasPermissions = true;
6008
+ }
5819
6009
  }
5820
6010
 
5821
6011
  const match = async (request, fetchSecondArg) => {
@@ -5983,6 +6173,26 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
5983
6173
  if (!resourceMatchResult) {
5984
6174
  continue;
5985
6175
  }
6176
+ // permissions check — done before method/media-type/upgrade checks so that
6177
+ // a hidden route does not leak its existence via 405/415/426 responses.
6178
+ // If the route's method would not have matched anyway we skip silently (no
6179
+ // wouldHaveMatched contribution). If the method would have matched we return
6180
+ // the denied response so the caller gets 404 or 403.
6181
+ if (someRouteHasPermissions) {
6182
+ const { permissionsRequired, permissionsToSee } = route;
6183
+ const denied = await checkRouteAccess(
6184
+ permissionsRequired,
6185
+ permissionsToSee,
6186
+ request,
6187
+ fetchSecondArg,
6188
+ );
6189
+ if (denied) {
6190
+ if (route.matchMethod(request.method)) {
6191
+ return denied;
6192
+ }
6193
+ continue;
6194
+ }
6195
+ }
5986
6196
  if (!route.matchMethod(request.method)) {
5987
6197
  if (!route.isFallback) {
5988
6198
  wouldHaveMatched.methodSet.add(route.method);
@@ -6165,10 +6375,68 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
6165
6375
 
6166
6376
  return matchRoutes(request);
6167
6377
  };
6168
- const inspect = ({ canExposeSensitiveData }) => {
6169
- // I want all the info I can gather about the routes
6378
+ const inspect = async ({ canExposeSensitiveData, fetchSecondArg }) => {
6170
6379
  const data = [];
6171
6380
  for (const route of routeSet) {
6381
+ if (someRouteHasPermissions) {
6382
+ const { permissionsRequired, permissionsToSee } = route;
6383
+ // route has no permission config → hidden from inspector
6384
+ // (unless canExposeSensitiveData is set, in which case we show it as admin-only visible)
6385
+ if (
6386
+ permissionsRequired === undefined &&
6387
+ permissionsToSee === undefined
6388
+ ) {
6389
+ if (!canExposeSensitiveData) {
6390
+ continue;
6391
+ }
6392
+ const routeData = route.toJSON({ canExposeSensitiveData });
6393
+ routeData.visibleBecauseAdmin = true;
6394
+ data.push(routeData);
6395
+ continue;
6396
+ }
6397
+ // anyone can access → always visible
6398
+ if (
6399
+ permissionsRequired !== undefined &&
6400
+ permissionsRequired.length > 0
6401
+ ) {
6402
+ if (fetchSecondArg) {
6403
+ const granted =
6404
+ await fetchSecondArg.hasPermissions(permissionsRequired);
6405
+ if (!granted) {
6406
+ // determine if route is normally visible (permissionsToSee satisfied)
6407
+ let normallyCanSee;
6408
+ if (permissionsToSee === undefined) {
6409
+ normallyCanSee = false;
6410
+ } else {
6411
+ const permsSet = await fetchSecondArg.getAllPermissions();
6412
+ normallyCanSee =
6413
+ permissionsToSee.length === 0 ||
6414
+ permissionsSatisfy$1(permsSet, permissionsToSee);
6415
+ }
6416
+ if (!normallyCanSee && !canExposeSensitiveData) {
6417
+ continue;
6418
+ }
6419
+ const routeData = route.toJSON({ canExposeSensitiveData });
6420
+ routeData.forbidden = true;
6421
+ if (!normallyCanSee) {
6422
+ routeData.visibleBecauseAdmin = true;
6423
+ }
6424
+ data.push(routeData);
6425
+ continue;
6426
+ }
6427
+ } else if (permissionsToSee === undefined) {
6428
+ if (!canExposeSensitiveData) {
6429
+ // no request context and route is not publicly visible
6430
+ continue;
6431
+ }
6432
+ const routeData = route.toJSON({ canExposeSensitiveData });
6433
+ routeData.forbidden = true;
6434
+ routeData.visibleBecauseAdmin = true;
6435
+ data.push(routeData);
6436
+ continue;
6437
+ }
6438
+ }
6439
+ }
6172
6440
  data.push(route.toJSON({ canExposeSensitiveData }));
6173
6441
  }
6174
6442
  return data;
@@ -6221,6 +6489,8 @@ const createRoute = ({
6221
6489
  headers,
6222
6490
  service,
6223
6491
  serverPlugin,
6492
+ permissionsRequired,
6493
+ permissionsToSee,
6224
6494
  availableMediaTypes = [],
6225
6495
  availableLanguages = [],
6226
6496
  availableVersions = [],
@@ -6271,6 +6541,8 @@ const createRoute = ({
6271
6541
  description,
6272
6542
  service,
6273
6543
  serverPlugin,
6544
+ permissionsRequired,
6545
+ permissionsToSee,
6274
6546
  availableMediaTypes,
6275
6547
  availableLanguages,
6276
6548
  availableVersions,
@@ -6358,6 +6630,59 @@ const createRoute = ({
6358
6630
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
6359
6631
  const isRedirectStatus = (status) => REDIRECT_STATUSES.has(status);
6360
6632
 
6633
+ // Returns a denied response if the request does not satisfy route access/visible config.
6634
+ // Returns null if access is granted.
6635
+ const checkRouteAccess = async (
6636
+ permissionsRequired,
6637
+ permissionsToSee,
6638
+ request,
6639
+ fetchSecondArg,
6640
+ ) => {
6641
+ // No permissionsRequired declared → route is hidden by default
6642
+ if (permissionsRequired === undefined) {
6643
+ return createRouteNotFoundResponse(request);
6644
+ }
6645
+ // permissionsRequired: [] → anyone can access
6646
+ if (permissionsRequired.length === 0) {
6647
+ return null;
6648
+ }
6649
+ // early-exit permission check (stops as soon as rule is satisfied)
6650
+ const granted = await fetchSecondArg.hasPermissions(permissionsRequired);
6651
+ if (granted) {
6652
+ return null;
6653
+ }
6654
+ // access denied — decide between 404 and 403
6655
+ if (permissionsToSee === undefined) {
6656
+ return createRouteNotFoundResponse(request);
6657
+ }
6658
+ // permissionsToSee check uses full permissions (already accumulated from hasPermissions call above)
6659
+ const permsSet = await fetchSecondArg.getAllPermissions();
6660
+ if (
6661
+ permissionsToSee.length === 0 ||
6662
+ permissionsSatisfy$1(permsSet, permissionsToSee)
6663
+ ) {
6664
+ return createForbiddenResponse();
6665
+ }
6666
+ return createRouteNotFoundResponse(request);
6667
+ };
6668
+
6669
+ const permissionsSatisfy$1 = (permissionsSet, permissionsRequired) => {
6670
+ for (const p of permissionsRequired) {
6671
+ if (!permissionsSet.has(p)) {
6672
+ return false;
6673
+ }
6674
+ }
6675
+ return true;
6676
+ };
6677
+
6678
+ const createForbiddenResponse = () => {
6679
+ return {
6680
+ status: 403,
6681
+ statusText: "Forbidden",
6682
+ headers: {},
6683
+ };
6684
+ };
6685
+
6361
6686
  const isRequestBodyMediaTypeSupported = (request, { acceptedMediaTypes }) => {
6362
6687
  const requestBodyContentType = request.headers["content-type"];
6363
6688
  if (!requestBodyContentType) {
@@ -6770,6 +7095,68 @@ const TIMING_NOOP = () => {
6770
7095
  return { end: () => {} };
6771
7096
  };
6772
7097
 
7098
+ const permissionsSatisfy = (permissionsSet, permissionsRequired) => {
7099
+ for (const p of permissionsRequired) {
7100
+ if (!permissionsSet.has(p)) {
7101
+ return false;
7102
+ }
7103
+ }
7104
+ return true;
7105
+ };
7106
+
7107
+ /**
7108
+ * Starts an HTTP (or HTTPS/HTTP2) server.
7109
+ *
7110
+ * @param {Object} params
7111
+ *
7112
+ * @param {Array<Object>} [params.routes=[]] - Route definitions for the server.
7113
+ * Each route is an object with the following properties:
7114
+ * - `endpoint` {string} — Required. A string like `"GET /users/:id"` combining the HTTP method
7115
+ * and the resource path. Use `"*"` as the method to match all methods, and URL patterns
7116
+ * such as `:param` for named segments or `*` for wildcards.
7117
+ * - `fetch` {Function} — Required. `(request, helpers) => response`. Must return a `Response`,
7118
+ * a response-like object `{ status, headers, body }`, or `null`/`undefined` to skip the route.
7119
+ * - `description` {string} — Optional human-readable description shown in the route inspector.
7120
+ * - `permissionsRequired` {Array<string>} — Optional. Permissions the client must hold to
7121
+ * access the route. An empty array means anyone can access. When omitted the route is hidden
7122
+ * by default (404 for everyone).
7123
+ * - `permissionsToSee` {Array<string>} — Optional. Permissions needed to know the route exists
7124
+ * (403 instead of 404 when access is denied). An empty array means the route is visible to
7125
+ * everyone even when access is denied.
7126
+ * - `availableMediaTypes` {Array<string>} — Content-types this route can produce (drives
7127
+ * `Accept` content negotiation).
7128
+ * - `availableLanguages` {Array<string>} — Languages this route can respond with.
7129
+ * - `availableEncodings` {Array<string>} — Encodings this route supports.
7130
+ * - `acceptedMediaTypes` {Array<string>} — Content-types accepted for request bodies
7131
+ * (POST/PATCH/PUT).
7132
+ * - `clientCodeExample` {string|Function} — Optional code snippet displayed in the route
7133
+ * inspector as a usage example.
7134
+ * - `declarationSource` {string} — Optional file URL of where the route is declared, shown
7135
+ * in the route inspector when `canExposeSensitiveData` is enabled.
7136
+ * - `headers` {Object} — Optional header pattern that must match for the route to be selected.
7137
+ *
7138
+ * @param {Array<Object>} [params.plugins=[]] - Server plugins that extend behaviour (hooks such
7139
+ * as `grantPermissions`, `redirectRequest`, `handleError`, etc.). See plugin documentation for
7140
+ * the exact shape; plugins are not described here in detail.
7141
+ *
7142
+ * @param {number} [params.port=0] - Port to listen on. Defaults to `0` which lets the OS assign
7143
+ * a random available port — useful in tests to avoid port conflicts. A fixed port such as
7144
+ * `3000` can be passed for a predictable address.
7145
+ *
7146
+ * @param {boolean} [params.acceptAnyIp=false] - When `true` the server binds to all network
7147
+ * interfaces (`0.0.0.0` / `::`), making it reachable from other machines on the network.
7148
+ * When `false` (default) it only listens on the configured `hostname`.
7149
+ *
7150
+ * @param {boolean} [params.canExposeSensitiveData=false] - Unlocks developer-facing features that
7151
+ * should never be enabled in production:
7152
+ * - Declaration source links in the route inspector (shows where each route is defined).
7153
+ * - The `/.internal/open_file` endpoint that can open files on the server machine.
7154
+ * - Auto-reload client notification when the server restarts.
7155
+ * - All routes are visible in the route inspector regardless of permission configuration.
7156
+ * Set to `true` during local development for a better DX.
7157
+ *
7158
+ * @returns {Promise<Object>} Resolves to the running server object with `{ origin, port, stop, … }`.
7159
+ */
6773
7160
  const startServer = async ({
6774
7161
  signal = new AbortController().signal,
6775
7162
  logLevel,
@@ -6880,6 +7267,7 @@ const startServer = async ({
6880
7267
 
6881
7268
  plugins = [
6882
7269
  ...(canExposeSensitiveData ? [serverPluginOpenFile()] : []),
7270
+ serverPluginResponseCookies(),
6883
7271
  serverPluginDefaultBody4xx5xx(),
6884
7272
  serverPluginRouteInspector({ canExposeSensitiveData }),
6885
7273
  ...(canExposeSensitiveData ? [serverPluginInternalClientFiles()] : []),
@@ -7064,6 +7452,7 @@ const startServer = async ({
7064
7452
  stopCallbackSet.clear();
7065
7453
  await Promise.all(promises);
7066
7454
  serverPluginsController.callHooks("serverStopped", { reason });
7455
+ await serverPluginsController.destroyAllPlugins();
7067
7456
  status = "stopped";
7068
7457
  stoppedResolve(reason);
7069
7458
  });
@@ -7291,6 +7680,48 @@ const startServer = async ({
7291
7680
  }
7292
7681
  },
7293
7682
  );
7683
+ // Build permission helpers, lazily per request.
7684
+ // A single shared iterator ensures each plugin is called at most once.
7685
+ // hasPermissions stops early once the rule is satisfied;
7686
+ // getAllPermissions drains all remaining plugins.
7687
+ const permissionsSet = new Set();
7688
+ const nextPermissionsHook =
7689
+ serverPluginsController.createAsyncHookIterator(
7690
+ "grantPermissions",
7691
+ request,
7692
+ );
7693
+ const drainPermissionsUntil = async (permissionsRequired) => {
7694
+ for (;;) {
7695
+ if (
7696
+ permissionsRequired !== undefined &&
7697
+ permissionsSatisfy(permissionsSet, permissionsRequired)
7698
+ ) {
7699
+ return true;
7700
+ }
7701
+ const { done, value } = await nextPermissionsHook();
7702
+ if (done) {
7703
+ break;
7704
+ }
7705
+ if (Array.isArray(value)) {
7706
+ for (const p of value) {
7707
+ permissionsSet.add(p);
7708
+ }
7709
+ }
7710
+ }
7711
+ return false;
7712
+ };
7713
+ const getAllPermissions = async () => {
7714
+ await drainPermissionsUntil(undefined);
7715
+ return permissionsSet;
7716
+ };
7717
+ const hasPermissions = async (permissionsRequired) => {
7718
+ if (permissionsRequired.length === 0) {
7719
+ return true;
7720
+ }
7721
+ return drainPermissionsUntil(permissionsRequired);
7722
+ };
7723
+ fetchSecondArg.getAllPermissions = getAllPermissions;
7724
+ fetchSecondArg.hasPermissions = hasPermissions;
7294
7725
  const routerResponseProperties = await router.match(
7295
7726
  request,
7296
7727
  fetchSecondArg,
package/package.json CHANGED
@@ -1,21 +1,22 @@
1
1
  {
2
2
  "name": "@jsenv/server",
3
- "version": "17.1.1",
3
+ "version": "17.3.0",
4
+ "type": "module",
4
5
  "description": "A modern Node.js HTTP server with declarative routing, content negotiation, and WebSocket support",
5
6
  "repository": {
6
7
  "type": "git",
7
8
  "url": "https://github.com/jsenv/core",
8
9
  "directory": "packages/backend/server"
9
10
  },
10
- "license": "MIT",
11
11
  "author": {
12
12
  "name": "dmail",
13
13
  "email": "dmaillard06@gmail.com"
14
14
  },
15
- "sideEffects": [
16
- "./dist/jsenv_server.js"
17
- ],
18
- "type": "module",
15
+ "license": "MIT",
16
+ "engines": {
17
+ "node": ">=22.13.1"
18
+ },
19
+ "main": "./index.js",
19
20
  "exports": {
20
21
  ".": {
21
22
  "import": {
@@ -25,11 +26,10 @@
25
26
  },
26
27
  "./*": "./*"
27
28
  },
28
- "main": "./index.js",
29
29
  "files": [
30
30
  "/dist/",
31
- "/src/",
32
- "/index.js"
31
+ "/index.js",
32
+ "/src/"
33
33
  ],
34
34
  "scripts": {
35
35
  "build": "node --experimental-import-meta-resolve ./scripts/build.mjs",
@@ -60,10 +60,10 @@
60
60
  "undici": "7.24.2",
61
61
  "ws": "8.20.0"
62
62
  },
63
- "engines": {
64
- "node": ">=22.13.1"
65
- },
66
63
  "publishConfig": {
67
64
  "access": "public"
68
- }
65
+ },
66
+ "sideEffects": [
67
+ "./dist/jsenv_server.js"
68
+ ]
69
69
  }
@@ -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
  };