@naturalcycles/backend-lib 9.82.0 → 9.84.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.
@@ -31,7 +31,7 @@ export interface RequireAdminCfg {
31
31
  /**
32
32
  * @deprecated Use `AuthMiddleware`.
33
33
  */
34
- export type AdminMiddleware = (reqPermissions?: string[], cfg?: RequireAdminCfg) => BackendRequestHandler;
34
+ export type AdminMiddleware = (reqPermissions?: readonly string[], cfg?: RequireAdminCfg) => BackendRequestHandler;
35
35
  /**
36
36
  * @deprecated Use `AuthService.getLoginRedirectMiddleware`.
37
37
  */
@@ -44,7 +44,7 @@ export declare function createAdminMiddleware(adminService: BaseAdminService, cf
44
44
  *
45
45
  * @deprecated Use `AuthService.getLoginRedirectMiddleware`.
46
46
  */
47
- export declare function requireAdminPermissions(adminService: BaseAdminService, reqPermissions?: string[], cfg?: RequireAdminCfg): BackendRequestHandler;
47
+ export declare function requireAdminPermissions(adminService: BaseAdminService, reqPermissions?: readonly string[], cfg?: RequireAdminCfg): BackendRequestHandler;
48
48
  /**
49
49
  * @deprecated Use `loginHtmlHandler`.
50
50
  */
@@ -19,8 +19,8 @@ export declare class BaseAdminService {
19
19
  * Returns AdminInfo if it has all required permissions.
20
20
  * Otherwise returns undefined
21
21
  */
22
- hasPermissions(req: BackendRequest, reqPermissions?: string[], meta?: AnyObject): Promise<AdminInfo | undefined>;
23
- requirePermissions(req: BackendRequest, reqPermissions?: string[], meta?: AnyObject, andComparison?: boolean): Promise<AdminInfo>;
22
+ hasPermissions(req: BackendRequest, reqPermissions?: readonly string[], meta?: AnyObject): Promise<AdminInfo | undefined>;
23
+ requirePermissions(req: BackendRequest, reqPermissions?: readonly string[], meta?: AnyObject, andComparison?: boolean): Promise<AdminInfo>;
24
24
  /**
25
25
  * Doesn't check permissions, nor `authEnabled`. See `checkPermissions`.
26
26
  */
@@ -28,7 +28,7 @@ export declare class BaseAdminService {
28
28
  /**
29
29
  * Doesn't log or throw. See `requireGranted`.
30
30
  */
31
- checkPermissions(admin: ResolvedAdmin, reqPermissions?: string[], opt?: CheckPermissionsOptions): CheckPermissionsResult;
31
+ checkPermissions(admin: ResolvedAdmin, reqPermissions?: readonly string[], opt?: CheckPermissionsOptions): CheckPermissionsResult;
32
32
  /**
33
33
  * Throws 401 if there's no email, 403 if not granted.
34
34
  */
@@ -51,7 +51,7 @@ export declare class BaseAdminService {
51
51
  /**
52
52
  * To be extended.
53
53
  */
54
- protected onPermissionCheck(req: BackendRequest, email: string, reqPermissions: string[], required: boolean, granted: boolean, meta?: AnyObject): Promise<void>;
54
+ protected onPermissionCheck(req: BackendRequest, email: string, reqPermissions: readonly string[], required: boolean, granted: boolean, meta?: AnyObject): Promise<void>;
55
55
  /**
56
56
  * Install it on POST /admin/login url
57
57
  *
@@ -113,7 +113,7 @@ export interface CheckPermissionsResult {
113
113
  email?: string;
114
114
  isAdmin: boolean;
115
115
  granted: boolean;
116
- reqPermissions: string[];
117
- grantedPermissions: string[];
116
+ reqPermissions: readonly string[];
117
+ grantedPermissions: readonly string[];
118
118
  authDisabled: boolean;
119
119
  }
@@ -27,7 +27,7 @@ export declare class AuthService<CTX = AnyObject> {
27
27
  * Returns the granted permissions (all of the subject's if alwaysAuthorized),
28
28
  * or `{ subject: 'authDisabled', permissions: [] }` if auth is disabled.
29
29
  */
30
- require(resolved: ResolvedAuth | undefined, reqPermissions?: string[], opt?: AuthCheckOptions<CTX>): PermissionInfo;
30
+ require(resolved: ResolvedAuth | undefined, reqPermissions?: readonly string[], opt?: AuthCheckOptions<CTX>): PermissionInfo;
31
31
  private checkSubject;
32
32
  private assertAuthenticated;
33
33
  private authRequiredData;
@@ -67,9 +67,9 @@ export interface AuthCheck {
67
67
  subject: string;
68
68
  granted: boolean;
69
69
  alwaysAuthorized: boolean;
70
- reqPermissions: string[];
70
+ reqPermissions: readonly string[];
71
71
  /** The granted permissions, or reqPermissions if denied */
72
- checkedPermissions: string[];
72
+ checkedPermissions: readonly string[];
73
73
  }
74
74
  export interface AuthLoginRedirectMiddlewareCfg<CTX> extends AuthMiddlewareCfg<CTX> {
75
75
  loginHtmlPath: string;
@@ -19,10 +19,10 @@ export declare class HttpDB extends BaseCommonDB implements CommonDB {
19
19
  getTables(): Promise<string[]>;
20
20
  getTableSchema<ROW extends ObjectWithId>(table: string): Promise<JsonSchema<ROW>>;
21
21
  resetCache(table?: string): Promise<void>;
22
- getByIds<ROW extends ObjectWithId>(table: string, ids: ROW['id'][], opt?: CommonDBOptions): Promise<ROW[]>;
22
+ getByIds<ROW extends ObjectWithId>(table: string, ids: readonly ROW['id'][], opt?: CommonDBOptions): Promise<ROW[]>;
23
23
  runQuery<ROW extends ObjectWithId>(query: DBQuery<ROW>, opt?: CommonDBOptions): Promise<RunQueryResult<ROW>>;
24
24
  runQueryCount<ROW extends ObjectWithId>(query: DBQuery<ROW>, opt?: CommonDBOptions): Promise<number>;
25
- saveBatch<ROW extends ObjectWithId>(table: string, rows: ROW[], opt?: CommonDBSaveOptions<ROW>): Promise<void>;
26
- deleteByIds(table: string, ids: string[], opt?: CommonDBOptions): Promise<number>;
25
+ saveBatch<ROW extends ObjectWithId>(table: string, rows: readonly ROW[], opt?: CommonDBSaveOptions<ROW>): Promise<void>;
26
+ deleteByIds(table: string, ids: readonly string[], opt?: CommonDBOptions): Promise<number>;
27
27
  deleteByQuery<ROW extends ObjectWithId>(query: DBQuery<ROW>, opt?: CommonDBOptions): Promise<number>;
28
28
  }
@@ -2,4 +2,4 @@ import type { BackendRequestHandler, BackendRouter } from '../server/server.mode
2
2
  /**
3
3
  * Convenience method.
4
4
  */
5
- export declare function getDefaultRouter(defaultHandlers?: BackendRequestHandler[]): BackendRouter;
5
+ export declare function getDefaultRouter(defaultHandlers?: readonly BackendRequestHandler[]): BackendRouter;
@@ -2,7 +2,7 @@ import { _mb } from '@naturalcycles/js-lib';
2
2
  import { _sum } from '@naturalcycles/js-lib/array/array.util.js';
3
3
  import { _sortBy } from '@naturalcycles/js-lib/array/sort.js';
4
4
  import { _ms } from '@naturalcycles/js-lib/datetime/time.util.js';
5
- import { _percentile } from '@naturalcycles/js-lib/math/math.util.js';
5
+ import { _percentiles } from '@naturalcycles/js-lib/math/math.util.js';
6
6
  import { NumberStack } from '@naturalcycles/js-lib/math/stack.util.js';
7
7
  import { _get, _mapValues } from '@naturalcycles/js-lib/object/object.util.js';
8
8
  import { htmlEscape } from '@naturalcycles/js-lib/string/escape.js';
@@ -31,6 +31,7 @@ export const serverStatsHTMLHandler = (req, res) => {
31
31
  s.pc = _mapValues(s.stack.percentiles(percentiles), (_k, v) => Math.round(v), { mutate: true });
32
32
  });
33
33
  const allLatencies = _stringMapValues(serverStatsMap).flatMap(s => s.stack.items);
34
+ const allPercentiles = _percentiles(allLatencies, percentiles);
34
35
  const all2xx = _sum(_stringMapValues(serverStatsMap).flatMap(s => s['2xx']));
35
36
  const all4xx = _sum(_stringMapValues(serverStatsMap).flatMap(s => s['4xx']));
36
37
  const all5xx = _sum(_stringMapValues(serverStatsMap).flatMap(s => s['5xx']));
@@ -55,7 +56,7 @@ export const serverStatsHTMLHandler = (req, res) => {
55
56
  `<td align="right"><pre>${all2xx}</pre></td>`,
56
57
  `<td align="right"><pre>${all4xx}</pre></td>`,
57
58
  `<td align="right"><pre>${all5xx}</pre></td>`,
58
- ...percentiles.map(pc => `<td align="right"><pre>${Math.round(_percentile(allLatencies, pc))}</pre></td>`),
59
+ ...percentiles.map(pc => `<td align="right"><pre>${Math.round(allPercentiles[pc])}</pre></td>`),
59
60
  `</tr>`,
60
61
  ..._sortBy(_stringMapEntries(serverStatsMap), ([_, stat]) => _get(stat, sortBy), {
61
62
  dir: asc ? 'asc' : 'desc',
@@ -7,7 +7,7 @@ export interface ExpressApp extends Fetcher, AsyncDisposable {
7
7
  declare class ExpressTestService {
8
8
  createAppFromResource(resource: BackendRequestHandlerCfg, opt?: FetcherOptions, defaultAppCfg?: DefaultAppCfg): Promise<ExpressApp>;
9
9
  createAppFromResources(resources: BackendRequestHandlerCfg[], opt?: FetcherOptions): Promise<ExpressApp>;
10
- createApp(app: BackendApplication, opt?: FetcherOptions): ExpressApp;
10
+ createApp(app: BackendApplication, opt?: FetcherOptions): Promise<ExpressApp>;
11
11
  /**
12
12
  * Creates a "Default Express App" with provided resources.
13
13
  * Starts an http server on '127.0.0.1' and random available port.
@@ -1,9 +1,10 @@
1
1
  import { getFetcher } from '@naturalcycles/js-lib/http';
2
2
  import { pDelay } from '@naturalcycles/js-lib/promise/pDelay.js';
3
3
  import { createDefaultApp } from '../express/createDefaultApp.js';
4
+ const TEST_SERVER_HOST = '127.0.0.1';
4
5
  const nativeFetchFn = async (url, init) => await globalThis.fetch(url, init);
5
6
  // Example:
6
- // const app = expressTestService.createApp([ debugResource ])
7
+ // const app = await expressTestService.createAppFromResource(debugResource)
7
8
  // afterAll(async () => {
8
9
  // await app.close()
9
10
  // })
@@ -19,10 +20,10 @@ class ExpressTestService {
19
20
  resources,
20
21
  }), opt);
21
22
  }
22
- createApp(app, opt) {
23
- const server = this.createTestServer(app);
23
+ async createApp(app, opt) {
24
+ const server = await this.createTestServer(app);
24
25
  const { port } = server.address();
25
- const baseUrl = `http://127.0.0.1:${port}`;
26
+ const baseUrl = `http://${TEST_SERVER_HOST}:${port}`;
26
27
  const fetcher = getFetcher({
27
28
  baseUrl,
28
29
  responseType: 'json',
@@ -63,14 +64,23 @@ class ExpressTestService {
63
64
  * const { port } = server.address() as AddressInfo
64
65
  * const url = `http://127.0.0.1:${port}`
65
66
  */
66
- createTestServer(app) {
67
+ async createTestServer(app) {
67
68
  // Important!
68
- // Only with this syntax `app.listen(0)` it allocates a port synchronously
69
- // Trying to specify a hostname will make server.address() return null.
70
- // This is how `supertest` is doing it.
71
- const server = app.listen(0);
69
+ // Listening on the same host that the Fetcher connects to.
70
+ // `app.listen(0)` (without a hostname) listens on all interfaces (`::`), and the OS (e.g macOS)
71
+ // may give it a port that some other process (e.g IntelliJ on 63342) already listens on
72
+ // at 127.0.0.1. Requests to 127.0.0.1 would then reach that other process instead.
73
+ // Listening on 127.0.0.1 makes the OS pick a port that is free on 127.0.0.1.
74
+ // The downside is that the port is only known after the async `listening` callback
75
+ // (server.address() returns null until then).
72
76
  // enableDestroy(server)
73
- return server;
77
+ return await new Promise((resolve, reject) => {
78
+ const server = app.listen(0, TEST_SERVER_HOST, (err) => {
79
+ if (err)
80
+ return reject(err);
81
+ resolve(server);
82
+ });
83
+ });
74
84
  }
75
85
  }
76
86
  export const expressTestService = new ExpressTestService();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/backend-lib",
3
3
  "type": "module",
4
- "version": "9.82.0",
4
+ "version": "9.84.0",
5
5
  "dependencies": {
6
6
  "@naturalcycles/db-lib": "^10",
7
7
  "@naturalcycles/js-lib": "^15",
@@ -42,7 +42,7 @@ export interface RequireAdminCfg {
42
42
  * @deprecated Use `AuthMiddleware`.
43
43
  */
44
44
  export type AdminMiddleware = (
45
- reqPermissions?: string[],
45
+ reqPermissions?: readonly string[],
46
46
  cfg?: RequireAdminCfg,
47
47
  ) => BackendRequestHandler
48
48
 
@@ -70,7 +70,7 @@ export function createAdminMiddleware(
70
70
  */
71
71
  export function requireAdminPermissions(
72
72
  adminService: BaseAdminService,
73
- reqPermissions: string[] = [],
73
+ reqPermissions: readonly string[] = [],
74
74
  cfg: RequireAdminCfg = {},
75
75
  ): BackendRequestHandler {
76
76
  const {
@@ -70,7 +70,7 @@ export class BaseAdminService {
70
70
  */
71
71
  async hasPermissions(
72
72
  req: BackendRequest,
73
- reqPermissions: string[] = [],
73
+ reqPermissions: readonly string[] = [],
74
74
  meta: AnyObject = {},
75
75
  ): Promise<AdminInfo | undefined> {
76
76
  if (!this.cfg.authEnabled) return adminInfoDisabled()
@@ -93,7 +93,7 @@ export class BaseAdminService {
93
93
 
94
94
  async requirePermissions(
95
95
  req: BackendRequest,
96
- reqPermissions: string[] = [],
96
+ reqPermissions: readonly string[] = [],
97
97
  meta: AnyObject = {},
98
98
  andComparison = true,
99
99
  ): Promise<AdminInfo> {
@@ -128,7 +128,7 @@ export class BaseAdminService {
128
128
  */
129
129
  checkPermissions(
130
130
  admin: ResolvedAdmin,
131
- reqPermissions: string[] = [],
131
+ reqPermissions: readonly string[] = [],
132
132
  opt: CheckPermissionsOptions = {},
133
133
  ): CheckPermissionsResult {
134
134
  const { andComparison = true } = opt
@@ -254,7 +254,7 @@ export class BaseAdminService {
254
254
  protected async onPermissionCheck(
255
255
  req: BackendRequest,
256
256
  email: string,
257
- reqPermissions: string[],
257
+ reqPermissions: readonly string[],
258
258
  required: boolean,
259
259
  granted: boolean,
260
260
  meta: AnyObject = {},
@@ -366,7 +366,7 @@ export interface CheckPermissionsResult {
366
366
  email?: string
367
367
  isAdmin: boolean
368
368
  granted: boolean
369
- reqPermissions: string[]
370
- grantedPermissions: string[]
369
+ reqPermissions: readonly string[]
370
+ grantedPermissions: readonly string[]
371
371
  authDisabled: boolean
372
372
  }
@@ -34,7 +34,7 @@ export function createSecureHeaderMiddleware(cfg: SecureHeaderMiddlewareCfg): Ad
34
34
 
35
35
  function requireSecureHeaderOrAdmin(
36
36
  cfg: SecureHeaderMiddlewareCfg,
37
- reqPermissions?: string[],
37
+ reqPermissions?: readonly string[],
38
38
  ): BackendRequestHandler {
39
39
  const { secureHeaderKey = 'Authorization', secureHeaderValue } = cfg
40
40
 
@@ -34,7 +34,7 @@ export class AuthService<CTX = AnyObject> {
34
34
 
35
35
  private createHandler(
36
36
  cfg: AuthMiddlewareCfg<CTX>,
37
- reqPermissions: string[] = [],
37
+ reqPermissions: readonly string[] = [],
38
38
  loginHref?: string,
39
39
  ): BackendRequestHandler {
40
40
  const {
@@ -100,7 +100,7 @@ export class AuthService<CTX = AnyObject> {
100
100
  */
101
101
  require(
102
102
  resolved: ResolvedAuth | undefined,
103
- reqPermissions: string[] = [],
103
+ reqPermissions: readonly string[] = [],
104
104
  opt: AuthCheckOptions<CTX> = {},
105
105
  ): PermissionInfo {
106
106
  if (!this.cfg.authEnabled) return { subject: 'authDisabled', permissions: [] }
@@ -120,9 +120,9 @@ export class AuthService<CTX = AnyObject> {
120
120
 
121
121
  private checkSubject(
122
122
  resolved: AuthenticatedAuth,
123
- reqPermissions: string[],
123
+ reqPermissions: readonly string[],
124
124
  opt: AuthCheckOptions<CTX>,
125
- ): string[] | undefined {
125
+ ): readonly string[] | undefined {
126
126
  const { subject, permissions, alwaysAuthorized = false } = resolved
127
127
  const { andComparison = true, ctx } = opt
128
128
 
@@ -160,8 +160,8 @@ export class AuthService<CTX = AnyObject> {
160
160
 
161
161
  private assertGranted(
162
162
  subject: string,
163
- grantedPermissions: string[] | undefined,
164
- reqPermissions: string[],
163
+ grantedPermissions: readonly string[] | undefined,
164
+ reqPermissions: readonly string[],
165
165
  ): asserts grantedPermissions is string[] {
166
166
  _assert(grantedPermissions, `Permissions required: [${reqPermissions.join(', ')}]`, {
167
167
  permissionsRequired: reqPermissions,
@@ -175,8 +175,8 @@ export class AuthService<CTX = AnyObject> {
175
175
  * undefined - not granted.
176
176
  */
177
177
  private getGrantedPermissions(
178
- permissions: string[] | undefined,
179
- reqPermissions: string[],
178
+ permissions: readonly string[] | undefined,
179
+ reqPermissions: readonly string[],
180
180
  andComparison: boolean,
181
181
  ): string[] | undefined {
182
182
  if (!permissions) return
@@ -242,9 +242,9 @@ export interface AuthCheck {
242
242
  subject: string
243
243
  granted: boolean
244
244
  alwaysAuthorized: boolean
245
- reqPermissions: string[]
245
+ reqPermissions: readonly string[]
246
246
  /** The granted permissions, or reqPermissions if denied */
247
- checkedPermissions: string[]
247
+ checkedPermissions: readonly string[]
248
248
  }
249
249
 
250
250
  export interface AuthLoginRedirectMiddlewareCfg<CTX> extends AuthMiddlewareCfg<CTX> {
package/src/db/httpDB.ts CHANGED
@@ -63,7 +63,7 @@ export class HttpDB extends BaseCommonDB implements CommonDB {
63
63
 
64
64
  override async getByIds<ROW extends ObjectWithId>(
65
65
  table: string,
66
- ids: ROW['id'][],
66
+ ids: readonly ROW['id'][],
67
67
  opt?: CommonDBOptions,
68
68
  ): Promise<ROW[]> {
69
69
  return await this.fetcher.put(`getByIds`, {
@@ -101,7 +101,7 @@ export class HttpDB extends BaseCommonDB implements CommonDB {
101
101
 
102
102
  override async saveBatch<ROW extends ObjectWithId>(
103
103
  table: string,
104
- rows: ROW[],
104
+ rows: readonly ROW[],
105
105
  opt?: CommonDBSaveOptions<ROW>,
106
106
  ): Promise<void> {
107
107
  await this.fetcher.putVoid(`saveBatch`, {
@@ -113,7 +113,11 @@ export class HttpDB extends BaseCommonDB implements CommonDB {
113
113
  })
114
114
  }
115
115
 
116
- override async deleteByIds(table: string, ids: string[], opt?: CommonDBOptions): Promise<number> {
116
+ override async deleteByIds(
117
+ table: string,
118
+ ids: readonly string[],
119
+ opt?: CommonDBOptions,
120
+ ): Promise<number> {
117
121
  return await this.fetcher.put(`deleteByIds`, {
118
122
  json: {
119
123
  table,
@@ -147,7 +147,10 @@ export async function createDefaultApp(cfg: DefaultAppCfg): Promise<BackendAppli
147
147
  return app
148
148
  }
149
149
 
150
- function useHandlers(app: BackendApplication, handlers: BackendRequestHandlerCfg[] = []): void {
150
+ function useHandlers(
151
+ app: BackendApplication,
152
+ handlers: readonly BackendRequestHandlerCfg[] = [],
153
+ ): void {
151
154
  handlers
152
155
  .map<BackendRequestHandlerWithPath>(cfg => {
153
156
  if (typeof cfg === 'function') {
@@ -4,7 +4,9 @@ import type { BackendRequestHandler, BackendRouter } from '../server/server.mode
4
4
  /**
5
5
  * Convenience method.
6
6
  */
7
- export function getDefaultRouter(defaultHandlers: BackendRequestHandler[] = []): BackendRouter {
7
+ export function getDefaultRouter(
8
+ defaultHandlers: readonly BackendRequestHandler[] = [],
9
+ ): BackendRouter {
8
10
  const router = Router()
9
11
 
10
12
  // Use default handlers
@@ -413,7 +413,7 @@ function compressSync(method: string, body: Buffer, opts: CodecOptions): Buffer
413
413
  function addListeners(
414
414
  stream: any,
415
415
  on: (type: string, listener: (...args: any[]) => void) => void,
416
- listeners: [string, (...args: any[]) => void][],
416
+ listeners: readonly [string, (...args: any[]) => void][],
417
417
  ): void {
418
418
  for (const [type, listener] of listeners) {
419
419
  on.call(stream, type, listener)
@@ -2,7 +2,7 @@ import { _mb } from '@naturalcycles/js-lib'
2
2
  import { _sum } from '@naturalcycles/js-lib/array/array.util.js'
3
3
  import { _sortBy } from '@naturalcycles/js-lib/array/sort.js'
4
4
  import { _ms } from '@naturalcycles/js-lib/datetime/time.util.js'
5
- import { _percentile } from '@naturalcycles/js-lib/math/math.util.js'
5
+ import { _percentiles } from '@naturalcycles/js-lib/math/math.util.js'
6
6
  import { NumberStack } from '@naturalcycles/js-lib/math/stack.util.js'
7
7
  import { _get, _mapValues } from '@naturalcycles/js-lib/object/object.util.js'
8
8
  import { htmlEscape } from '@naturalcycles/js-lib/string/escape.js'
@@ -51,6 +51,7 @@ export const serverStatsHTMLHandler: BackendRequestHandler = (req, res) => {
51
51
  s.pc = _mapValues(s.stack.percentiles(percentiles), (_k, v) => Math.round(v), { mutate: true })
52
52
  })
53
53
  const allLatencies = _stringMapValues(serverStatsMap).flatMap(s => s.stack.items)
54
+ const allPercentiles = _percentiles(allLatencies, percentiles)
54
55
  const all2xx = _sum(_stringMapValues(serverStatsMap).flatMap(s => s['2xx']))
55
56
  const all4xx = _sum(_stringMapValues(serverStatsMap).flatMap(s => s['4xx']))
56
57
  const all5xx = _sum(_stringMapValues(serverStatsMap).flatMap(s => s['5xx']))
@@ -79,7 +80,7 @@ export const serverStatsHTMLHandler: BackendRequestHandler = (req, res) => {
79
80
  `<td align="right"><pre>${all4xx}</pre></td>`,
80
81
  `<td align="right"><pre>${all5xx}</pre></td>`,
81
82
  ...percentiles.map(
82
- pc => `<td align="right"><pre>${Math.round(_percentile(allLatencies, pc))}</pre></td>`,
83
+ pc => `<td align="right"><pre>${Math.round(allPercentiles[pc]!)}</pre></td>`,
83
84
  ),
84
85
  `</tr>`,
85
86
  ..._sortBy(_stringMapEntries(serverStatsMap), ([_, stat]) => _get(stat, sortBy) as string, {
@@ -7,6 +7,8 @@ import { createDefaultApp } from '../express/createDefaultApp.js'
7
7
  import type { BackendRequestHandlerCfg, DefaultAppCfg } from '../express/createDefaultApp.js'
8
8
  import type { BackendApplication } from '../server/server.model.js'
9
9
 
10
+ const TEST_SERVER_HOST = '127.0.0.1'
11
+
10
12
  const nativeFetchFn: FetchFunction = async (url, init) => await globalThis.fetch(url, init)
11
13
 
12
14
  export interface ExpressApp extends Fetcher, AsyncDisposable {
@@ -14,7 +16,7 @@ export interface ExpressApp extends Fetcher, AsyncDisposable {
14
16
  }
15
17
 
16
18
  // Example:
17
- // const app = expressTestService.createApp([ debugResource ])
19
+ // const app = await expressTestService.createAppFromResource(debugResource)
18
20
  // afterAll(async () => {
19
21
  // await app.close()
20
22
  // })
@@ -46,10 +48,10 @@ class ExpressTestService {
46
48
  )
47
49
  }
48
50
 
49
- createApp(app: BackendApplication, opt?: FetcherOptions): ExpressApp {
50
- const server = this.createTestServer(app)
51
+ async createApp(app: BackendApplication, opt?: FetcherOptions): Promise<ExpressApp> {
52
+ const server = await this.createTestServer(app)
51
53
  const { port } = server.address() as AddressInfo
52
- const baseUrl = `http://127.0.0.1:${port}`
54
+ const baseUrl = `http://${TEST_SERVER_HOST}:${port}`
53
55
 
54
56
  const fetcher = getFetcher({
55
57
  baseUrl,
@@ -95,14 +97,22 @@ class ExpressTestService {
95
97
  * const { port } = server.address() as AddressInfo
96
98
  * const url = `http://127.0.0.1:${port}`
97
99
  */
98
- private createTestServer(app: BackendApplication): Server {
100
+ private async createTestServer(app: BackendApplication): Promise<Server> {
99
101
  // Important!
100
- // Only with this syntax `app.listen(0)` it allocates a port synchronously
101
- // Trying to specify a hostname will make server.address() return null.
102
- // This is how `supertest` is doing it.
103
- const server = app.listen(0)
102
+ // Listening on the same host that the Fetcher connects to.
103
+ // `app.listen(0)` (without a hostname) listens on all interfaces (`::`), and the OS (e.g macOS)
104
+ // may give it a port that some other process (e.g IntelliJ on 63342) already listens on
105
+ // at 127.0.0.1. Requests to 127.0.0.1 would then reach that other process instead.
106
+ // Listening on 127.0.0.1 makes the OS pick a port that is free on 127.0.0.1.
107
+ // The downside is that the port is only known after the async `listening` callback
108
+ // (server.address() returns null until then).
104
109
  // enableDestroy(server)
105
- return server
110
+ return await new Promise<Server>((resolve, reject) => {
111
+ const server = app.listen(0, TEST_SERVER_HOST, (err?: Error) => {
112
+ if (err) return reject(err)
113
+ resolve(server)
114
+ })
115
+ })
106
116
  }
107
117
  }
108
118
 
@@ -31,7 +31,7 @@ const REDACTED = 'REDACTED'
31
31
  /**
32
32
  * Mutates error
33
33
  */
34
- function redact(redactPaths: string[], obj: any, error: Error): void {
34
+ function redact(redactPaths: readonly string[], obj: any, error: Error): void {
35
35
  redactPaths
36
36
  .map(path => _get(obj, path) as string)
37
37
  .filter(Boolean)