@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.
@@ -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,6 +4335,42 @@ 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,
@@ -4248,6 +4423,7 @@ const createServerPluginsController = async (serverPlugins) => {
4248
4423
  serverListening: { type: "hook" },
4249
4424
  redirectRequest: { type: "hook" },
4250
4425
  augmentRouteFetchSecondArg: { type: "hook" },
4426
+ grantPermissions: { type: "hook" },
4251
4427
  handleError: { type: "hook" },
4252
4428
  onResponsePush: { type: "hook" },
4253
4429
  injectResponseProperties: { type: "hook" },
@@ -5675,6 +5851,7 @@ const createRouter = (
5675
5851
  ) => {
5676
5852
  const logger = createLogger({ logLevel });
5677
5853
  const routeSet = new Set();
5854
+ let someRouteHasPermissions = false;
5678
5855
 
5679
5856
  const constructAvailableEndpoints = () => {
5680
5857
  // TODO: memoize
@@ -5816,6 +5993,12 @@ const createRouter = (
5816
5993
  for (const routeDescription of routeDescriptionArray) {
5817
5994
  const route = createRoute(routeDescription);
5818
5995
  routeSet.add(route);
5996
+ if (
5997
+ route.permissionsRequired !== undefined ||
5998
+ route.permissionsToSee !== undefined
5999
+ ) {
6000
+ someRouteHasPermissions = true;
6001
+ }
5819
6002
  }
5820
6003
 
5821
6004
  const match = async (request, fetchSecondArg) => {
@@ -5983,6 +6166,26 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
5983
6166
  if (!resourceMatchResult) {
5984
6167
  continue;
5985
6168
  }
6169
+ // permissions check — done before method/media-type/upgrade checks so that
6170
+ // a hidden route does not leak its existence via 405/415/426 responses.
6171
+ // If the route's method would not have matched anyway we skip silently (no
6172
+ // wouldHaveMatched contribution). If the method would have matched we return
6173
+ // the denied response so the caller gets 404 or 403.
6174
+ if (someRouteHasPermissions) {
6175
+ const { permissionsRequired, permissionsToSee } = route;
6176
+ const denied = await checkRouteAccess(
6177
+ permissionsRequired,
6178
+ permissionsToSee,
6179
+ request,
6180
+ fetchSecondArg,
6181
+ );
6182
+ if (denied) {
6183
+ if (route.matchMethod(request.method)) {
6184
+ return denied;
6185
+ }
6186
+ continue;
6187
+ }
6188
+ }
5986
6189
  if (!route.matchMethod(request.method)) {
5987
6190
  if (!route.isFallback) {
5988
6191
  wouldHaveMatched.methodSet.add(route.method);
@@ -6165,10 +6368,68 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
6165
6368
 
6166
6369
  return matchRoutes(request);
6167
6370
  };
6168
- const inspect = ({ canExposeSensitiveData }) => {
6169
- // I want all the info I can gather about the routes
6371
+ const inspect = async ({ canExposeSensitiveData, fetchSecondArg }) => {
6170
6372
  const data = [];
6171
6373
  for (const route of routeSet) {
6374
+ if (someRouteHasPermissions) {
6375
+ const { permissionsRequired, permissionsToSee } = route;
6376
+ // route has no permission config → hidden from inspector
6377
+ // (unless canExposeSensitiveData is set, in which case we show it as admin-only visible)
6378
+ if (
6379
+ permissionsRequired === undefined &&
6380
+ permissionsToSee === undefined
6381
+ ) {
6382
+ if (!canExposeSensitiveData) {
6383
+ continue;
6384
+ }
6385
+ const routeData = route.toJSON({ canExposeSensitiveData });
6386
+ routeData.visibleBecauseAdmin = true;
6387
+ data.push(routeData);
6388
+ continue;
6389
+ }
6390
+ // anyone can access → always visible
6391
+ if (
6392
+ permissionsRequired !== undefined &&
6393
+ permissionsRequired.length > 0
6394
+ ) {
6395
+ if (fetchSecondArg) {
6396
+ const granted =
6397
+ await fetchSecondArg.hasPermissions(permissionsRequired);
6398
+ if (!granted) {
6399
+ // determine if route is normally visible (permissionsToSee satisfied)
6400
+ let normallyCanSee;
6401
+ if (permissionsToSee === undefined) {
6402
+ normallyCanSee = false;
6403
+ } else {
6404
+ const permsSet = await fetchSecondArg.getAllPermissions();
6405
+ normallyCanSee =
6406
+ permissionsToSee.length === 0 ||
6407
+ permissionsSatisfy$1(permsSet, permissionsToSee);
6408
+ }
6409
+ if (!normallyCanSee && !canExposeSensitiveData) {
6410
+ continue;
6411
+ }
6412
+ const routeData = route.toJSON({ canExposeSensitiveData });
6413
+ routeData.forbidden = true;
6414
+ if (!normallyCanSee) {
6415
+ routeData.visibleBecauseAdmin = true;
6416
+ }
6417
+ data.push(routeData);
6418
+ continue;
6419
+ }
6420
+ } else if (permissionsToSee === undefined) {
6421
+ if (!canExposeSensitiveData) {
6422
+ // no request context and route is not publicly visible
6423
+ continue;
6424
+ }
6425
+ const routeData = route.toJSON({ canExposeSensitiveData });
6426
+ routeData.forbidden = true;
6427
+ routeData.visibleBecauseAdmin = true;
6428
+ data.push(routeData);
6429
+ continue;
6430
+ }
6431
+ }
6432
+ }
6172
6433
  data.push(route.toJSON({ canExposeSensitiveData }));
6173
6434
  }
6174
6435
  return data;
@@ -6221,6 +6482,8 @@ const createRoute = ({
6221
6482
  headers,
6222
6483
  service,
6223
6484
  serverPlugin,
6485
+ permissionsRequired,
6486
+ permissionsToSee,
6224
6487
  availableMediaTypes = [],
6225
6488
  availableLanguages = [],
6226
6489
  availableVersions = [],
@@ -6271,6 +6534,8 @@ const createRoute = ({
6271
6534
  description,
6272
6535
  service,
6273
6536
  serverPlugin,
6537
+ permissionsRequired,
6538
+ permissionsToSee,
6274
6539
  availableMediaTypes,
6275
6540
  availableLanguages,
6276
6541
  availableVersions,
@@ -6358,6 +6623,59 @@ const createRoute = ({
6358
6623
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
6359
6624
  const isRedirectStatus = (status) => REDIRECT_STATUSES.has(status);
6360
6625
 
6626
+ // Returns a denied response if the request does not satisfy route access/visible config.
6627
+ // Returns null if access is granted.
6628
+ const checkRouteAccess = async (
6629
+ permissionsRequired,
6630
+ permissionsToSee,
6631
+ request,
6632
+ fetchSecondArg,
6633
+ ) => {
6634
+ // No permissionsRequired declared → route is hidden by default
6635
+ if (permissionsRequired === undefined) {
6636
+ return createRouteNotFoundResponse(request);
6637
+ }
6638
+ // permissionsRequired: [] → anyone can access
6639
+ if (permissionsRequired.length === 0) {
6640
+ return null;
6641
+ }
6642
+ // early-exit permission check (stops as soon as rule is satisfied)
6643
+ const granted = await fetchSecondArg.hasPermissions(permissionsRequired);
6644
+ if (granted) {
6645
+ return null;
6646
+ }
6647
+ // access denied — decide between 404 and 403
6648
+ if (permissionsToSee === undefined) {
6649
+ return createRouteNotFoundResponse(request);
6650
+ }
6651
+ // permissionsToSee check uses full permissions (already accumulated from hasPermissions call above)
6652
+ const permsSet = await fetchSecondArg.getAllPermissions();
6653
+ if (
6654
+ permissionsToSee.length === 0 ||
6655
+ permissionsSatisfy$1(permsSet, permissionsToSee)
6656
+ ) {
6657
+ return createForbiddenResponse();
6658
+ }
6659
+ return createRouteNotFoundResponse(request);
6660
+ };
6661
+
6662
+ const permissionsSatisfy$1 = (permissionsSet, permissionsRequired) => {
6663
+ for (const p of permissionsRequired) {
6664
+ if (!permissionsSet.has(p)) {
6665
+ return false;
6666
+ }
6667
+ }
6668
+ return true;
6669
+ };
6670
+
6671
+ const createForbiddenResponse = () => {
6672
+ return {
6673
+ status: 403,
6674
+ statusText: "Forbidden",
6675
+ headers: {},
6676
+ };
6677
+ };
6678
+
6361
6679
  const isRequestBodyMediaTypeSupported = (request, { acceptedMediaTypes }) => {
6362
6680
  const requestBodyContentType = request.headers["content-type"];
6363
6681
  if (!requestBodyContentType) {
@@ -6770,6 +7088,68 @@ const TIMING_NOOP = () => {
6770
7088
  return { end: () => {} };
6771
7089
  };
6772
7090
 
7091
+ const permissionsSatisfy = (permissionsSet, permissionsRequired) => {
7092
+ for (const p of permissionsRequired) {
7093
+ if (!permissionsSet.has(p)) {
7094
+ return false;
7095
+ }
7096
+ }
7097
+ return true;
7098
+ };
7099
+
7100
+ /**
7101
+ * Starts an HTTP (or HTTPS/HTTP2) server.
7102
+ *
7103
+ * @param {Object} params
7104
+ *
7105
+ * @param {Array<Object>} [params.routes=[]] - Route definitions for the server.
7106
+ * Each route is an object with the following properties:
7107
+ * - `endpoint` {string} — Required. A string like `"GET /users/:id"` combining the HTTP method
7108
+ * and the resource path. Use `"*"` as the method to match all methods, and URL patterns
7109
+ * such as `:param` for named segments or `*` for wildcards.
7110
+ * - `fetch` {Function} — Required. `(request, helpers) => response`. Must return a `Response`,
7111
+ * a response-like object `{ status, headers, body }`, or `null`/`undefined` to skip the route.
7112
+ * - `description` {string} — Optional human-readable description shown in the route inspector.
7113
+ * - `permissionsRequired` {Array<string>} — Optional. Permissions the client must hold to
7114
+ * access the route. An empty array means anyone can access. When omitted the route is hidden
7115
+ * by default (404 for everyone).
7116
+ * - `permissionsToSee` {Array<string>} — Optional. Permissions needed to know the route exists
7117
+ * (403 instead of 404 when access is denied). An empty array means the route is visible to
7118
+ * everyone even when access is denied.
7119
+ * - `availableMediaTypes` {Array<string>} — Content-types this route can produce (drives
7120
+ * `Accept` content negotiation).
7121
+ * - `availableLanguages` {Array<string>} — Languages this route can respond with.
7122
+ * - `availableEncodings` {Array<string>} — Encodings this route supports.
7123
+ * - `acceptedMediaTypes` {Array<string>} — Content-types accepted for request bodies
7124
+ * (POST/PATCH/PUT).
7125
+ * - `clientCodeExample` {string|Function} — Optional code snippet displayed in the route
7126
+ * inspector as a usage example.
7127
+ * - `declarationSource` {string} — Optional file URL of where the route is declared, shown
7128
+ * in the route inspector when `canExposeSensitiveData` is enabled.
7129
+ * - `headers` {Object} — Optional header pattern that must match for the route to be selected.
7130
+ *
7131
+ * @param {Array<Object>} [params.plugins=[]] - Server plugins that extend behaviour (hooks such
7132
+ * as `grantPermissions`, `redirectRequest`, `handleError`, etc.). See plugin documentation for
7133
+ * the exact shape; plugins are not described here in detail.
7134
+ *
7135
+ * @param {number} [params.port=0] - Port to listen on. Defaults to `0` which lets the OS assign
7136
+ * a random available port — useful in tests to avoid port conflicts. A fixed port such as
7137
+ * `3000` can be passed for a predictable address.
7138
+ *
7139
+ * @param {boolean} [params.acceptAnyIp=false] - When `true` the server binds to all network
7140
+ * interfaces (`0.0.0.0` / `::`), making it reachable from other machines on the network.
7141
+ * When `false` (default) it only listens on the configured `hostname`.
7142
+ *
7143
+ * @param {boolean} [params.canExposeSensitiveData=false] - Unlocks developer-facing features that
7144
+ * should never be enabled in production:
7145
+ * - Declaration source links in the route inspector (shows where each route is defined).
7146
+ * - The `/.internal/open_file` endpoint that can open files on the server machine.
7147
+ * - Auto-reload client notification when the server restarts.
7148
+ * - All routes are visible in the route inspector regardless of permission configuration.
7149
+ * Set to `true` during local development for a better DX.
7150
+ *
7151
+ * @returns {Promise<Object>} Resolves to the running server object with `{ origin, port, stop, … }`.
7152
+ */
6773
7153
  const startServer = async ({
6774
7154
  signal = new AbortController().signal,
6775
7155
  logLevel,
@@ -6880,6 +7260,7 @@ const startServer = async ({
6880
7260
 
6881
7261
  plugins = [
6882
7262
  ...(canExposeSensitiveData ? [serverPluginOpenFile()] : []),
7263
+ serverPluginResponseCookies(),
6883
7264
  serverPluginDefaultBody4xx5xx(),
6884
7265
  serverPluginRouteInspector({ canExposeSensitiveData }),
6885
7266
  ...(canExposeSensitiveData ? [serverPluginInternalClientFiles()] : []),
@@ -7291,6 +7672,48 @@ const startServer = async ({
7291
7672
  }
7292
7673
  },
7293
7674
  );
7675
+ // Build permission helpers, lazily per request.
7676
+ // A single shared iterator ensures each plugin is called at most once.
7677
+ // hasPermissions stops early once the rule is satisfied;
7678
+ // getAllPermissions drains all remaining plugins.
7679
+ const permissionsSet = new Set();
7680
+ const nextPermissionsHook =
7681
+ serverPluginsController.createAsyncHookIterator(
7682
+ "grantPermissions",
7683
+ request,
7684
+ );
7685
+ const drainPermissionsUntil = async (permissionsRequired) => {
7686
+ for (;;) {
7687
+ if (
7688
+ permissionsRequired !== undefined &&
7689
+ permissionsSatisfy(permissionsSet, permissionsRequired)
7690
+ ) {
7691
+ return true;
7692
+ }
7693
+ const { done, value } = await nextPermissionsHook();
7694
+ if (done) {
7695
+ break;
7696
+ }
7697
+ if (Array.isArray(value)) {
7698
+ for (const p of value) {
7699
+ permissionsSet.add(p);
7700
+ }
7701
+ }
7702
+ }
7703
+ return false;
7704
+ };
7705
+ const getAllPermissions = async () => {
7706
+ await drainPermissionsUntil(undefined);
7707
+ return permissionsSet;
7708
+ };
7709
+ const hasPermissions = async (permissionsRequired) => {
7710
+ if (permissionsRequired.length === 0) {
7711
+ return true;
7712
+ }
7713
+ return drainPermissionsUntil(permissionsRequired);
7714
+ };
7715
+ fetchSecondArg.getAllPermissions = getAllPermissions;
7716
+ fetchSecondArg.hasPermissions = hasPermissions;
7294
7717
  const routerResponseProperties = await router.match(
7295
7718
  request,
7296
7719
  fetchSecondArg,
@@ -8005,17 +8428,32 @@ const generateAccessControlHeaders = ({
8005
8428
  } = {}) => {
8006
8429
  const vary = [];
8007
8430
 
8008
- const allowedOriginArray = [...accessControlAllowedOrigins];
8009
- if (accessControlAllowRequestOrigin) {
8010
- if ("origin" in headers && headers.origin !== "null") {
8011
- allowedOriginArray.push(headers.origin);
8431
+ // Access-Control-Allow-Origin must be a single value (not a list).
8432
+ // We reflect back the request's origin if it is in the allowed list.
8433
+ // If no origin matches we fall back to "*" (only when not using credentials).
8434
+ let allowOrigin = null;
8435
+
8436
+ const requestOrigin =
8437
+ "origin" in headers && headers.origin !== "null"
8438
+ ? headers.origin
8439
+ : "referer" in headers
8440
+ ? new URL(headers.referer).origin
8441
+ : null;
8442
+
8443
+ if (requestOrigin) {
8444
+ if (accessControlAllowedOrigins.includes(requestOrigin)) {
8445
+ allowOrigin = requestOrigin;
8446
+ vary.push("origin");
8447
+ } else if (accessControlAllowRequestOrigin) {
8448
+ allowOrigin = requestOrigin;
8012
8449
  vary.push("origin");
8013
- } else if ("referer" in headers) {
8014
- allowedOriginArray.push(new URL(headers.referer).origin);
8015
- vary.push("referer");
8016
- } else {
8017
- allowedOriginArray.push("*");
8018
8450
  }
8451
+ } else if (accessControlAllowRequestOrigin) {
8452
+ allowOrigin = "*";
8453
+ }
8454
+
8455
+ if (allowOrigin === null) {
8456
+ allowOrigin = accessControlAllowedOrigins[0] ?? "*";
8019
8457
  }
8020
8458
 
8021
8459
  const allowedMethodArray = [...accessControlAllowedMethods];
@@ -8049,16 +8487,14 @@ const generateAccessControlHeaders = ({
8049
8487
  }
8050
8488
 
8051
8489
  return {
8052
- "access-control-allow-origin": allowedOriginArray.join(", "),
8490
+ "access-control-allow-origin": allowOrigin,
8053
8491
  "access-control-allow-methods": allowedMethodArray.join(", "),
8054
8492
  "access-control-allow-headers": allowedHeaderArray.join(", "),
8055
8493
  ...(accessControlAllowCredentials
8056
8494
  ? { "access-control-allow-credentials": true }
8057
8495
  : {}),
8058
8496
  "access-control-max-age": accessControlMaxAge,
8059
- ...(timingAllowOrigin
8060
- ? { "timing-allow-origin": allowedOriginArray.join(", ") }
8061
- : {}),
8497
+ ...(timingAllowOrigin ? { "timing-allow-origin": allowOrigin } : {}),
8062
8498
  ...(vary.length ? { vary: vary.join(", ") } : {}),
8063
8499
  };
8064
8500
  };
package/package.json CHANGED
@@ -1,21 +1,22 @@
1
1
  {
2
2
  "name": "@jsenv/server",
3
- "version": "17.1.0",
3
+ "version": "17.2.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
  }