@devmoods/express-extras 0.76.1 → 0.77.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.
@@ -0,0 +1,185 @@
1
+ import { config } from '@devmoods/config';
2
+ import { startTimer } from '@devmoods/observability';
3
+ import { JOB_ID_CONTEXT_KEY, requestContext, setRequestContext, } from './context.js';
4
+ import {} from './jobs.js';
5
+ import { getLogger } from './logger.js';
6
+ import {} from './redis.js';
7
+ const logger = getLogger('redis-jobs');
8
+ const pollMs = 100;
9
+ // One atomic claim moves due or abandoned jobs to the ready set, then reserves
10
+ // the highest priority job. A worker renews its reservation while it runs.
11
+ const claimScript = `
12
+ for _, source in ipairs({KEYS[2], KEYS[3]}) do
13
+ local due = redis.call('ZRANGEBYSCORE', source, '-inf', ARGV[1], 'LIMIT', 0, 100)
14
+ for _, job in ipairs(due) do
15
+ if redis.call('ZREM', source, job) == 1 then
16
+ redis.call('ZADD', KEYS[1], cjson.decode(job).priority or 0, job)
17
+ end
18
+ end
19
+ end
20
+ local claimed = redis.call('ZPOPMAX', KEYS[1], 1)
21
+ if #claimed == 0 then return nil end
22
+ redis.call('ZADD', KEYS[3], ARGV[2], claimed[1])
23
+ return claimed[1]
24
+ `;
25
+ const finishScript = `
26
+ if redis.call('ZREM', KEYS[1], ARGV[1]) == 1 and ARGV[2] ~= '' then
27
+ redis.call('ZADD', KEYS[2], ARGV[3], ARGV[2])
28
+ end
29
+ `;
30
+ /** Persistent, at least once job processing using Redis sorted sets. */
31
+ export class RedisJobBackend {
32
+ redis;
33
+ options;
34
+ constructor(redis, options = {}) {
35
+ this.redis = redis;
36
+ this.options = options;
37
+ }
38
+ key(queue, state) {
39
+ return `${this.options.keyPrefix ?? `${config.REDIS_KEY_PREFIX}jobs:`}${queue}:${state}`;
40
+ }
41
+ get leaseMs() {
42
+ return this.options.leaseMs ?? 30_000;
43
+ }
44
+ async enqueue(job) {
45
+ const at = job.at == null ? Date.now() : new Date(job.at).getTime();
46
+ if (!Number.isFinite(at)) {
47
+ throw new RangeError('Invalid job schedule');
48
+ }
49
+ if (job.priority != null && !Number.isFinite(job.priority)) {
50
+ throw new RangeError('Invalid job priority');
51
+ }
52
+ if (job.retry != null && (!Number.isInteger(job.retry) || job.retry < 0)) {
53
+ throw new RangeError('Invalid job retry count');
54
+ }
55
+ const payload = JSON.stringify({ ...job, attempt: 0 });
56
+ const scheduled = at > Date.now();
57
+ await this.redis.zAdd(this.key(job.queue, scheduled ? 'scheduled' : 'ready'), {
58
+ score: scheduled ? at : (job.priority ?? 0),
59
+ value: payload,
60
+ });
61
+ }
62
+ async claim(queue) {
63
+ const now = Date.now();
64
+ const value = await this.redis.eval(claimScript, {
65
+ keys: [
66
+ this.key(queue, 'ready'),
67
+ this.key(queue, 'scheduled'),
68
+ this.key(queue, 'processing'),
69
+ ],
70
+ arguments: [String(now), String(now + this.leaseMs)],
71
+ });
72
+ return value == null ? null : JSON.parse(String(value));
73
+ }
74
+ async finish(job, next, failed = false) {
75
+ const nextValue = next ? JSON.stringify(next) : '';
76
+ const score = next
77
+ ? failed
78
+ ? Date.now()
79
+ : Date.now() + Math.min(60_000, 1000 * 2 ** (next.attempt - 1))
80
+ : 0;
81
+ await this.redis.eval(finishScript, {
82
+ keys: [
83
+ this.key(job.queue, 'processing'),
84
+ this.key(job.queue, failed ? 'failed' : 'scheduled'),
85
+ ],
86
+ arguments: [JSON.stringify(job), nextValue, String(score)],
87
+ });
88
+ }
89
+ createWorker({ jobs, queues, jobContext, }) {
90
+ const claim = (queue) => this.claim(queue);
91
+ const availableJobs = new Map(jobs
92
+ .filter((job) => !!job && Object.prototype.hasOwnProperty.call(job, '$job'))
93
+ .map((job) => [job.jobName, job]));
94
+ let running = false;
95
+ let loop;
96
+ let wake;
97
+ let queueIndex = 0;
98
+ const wait = () => new Promise((resolve) => {
99
+ const timer = setTimeout(resolve, pollMs);
100
+ wake = () => {
101
+ clearTimeout(timer);
102
+ resolve();
103
+ };
104
+ });
105
+ const processJob = async (job) => {
106
+ const registered = availableJobs.get(job.jobName);
107
+ const timer = startTimer();
108
+ const heartbeat = setInterval(() => {
109
+ void this.redis
110
+ .zAdd(this.key(job.queue, 'processing'), {
111
+ score: Date.now() + this.leaseMs,
112
+ value: JSON.stringify(job),
113
+ }, { XX: true })
114
+ .catch((error) => logger.error(error));
115
+ }, this.leaseMs / 3);
116
+ try {
117
+ if (!registered) {
118
+ throw new Error(`Unknown job: ${job.jobName}`);
119
+ }
120
+ logger.info('job started', { jobName: job.jobName, jobId: job.jid });
121
+ await requestContext.run(new Map(), async () => {
122
+ setRequestContext(JOB_ID_CONTEXT_KEY, job.jid);
123
+ await registered.execute(job.args, jobContext ?? {});
124
+ });
125
+ await this.finish(job);
126
+ logger.info('job succeeded', {
127
+ jobName: job.jobName,
128
+ jobId: job.jid,
129
+ duration: timer.getDuration(),
130
+ });
131
+ }
132
+ catch (error) {
133
+ logger.error(error, 'job failed', {
134
+ jobName: job.jobName,
135
+ jobId: job.jid,
136
+ });
137
+ const next = { ...job, attempt: job.attempt + 1 };
138
+ const failed = next.attempt > (job.retry ?? 25) || !registered;
139
+ await this.finish(job, next, failed);
140
+ }
141
+ finally {
142
+ clearInterval(heartbeat);
143
+ }
144
+ };
145
+ return {
146
+ async start() {
147
+ if (running) {
148
+ return;
149
+ }
150
+ running = true;
151
+ logger.info('redis job worker started', { queues });
152
+ loop = (async () => {
153
+ while (running) {
154
+ try {
155
+ let found = false;
156
+ for (let i = 0; i < queues.length && running; i++) {
157
+ const queue = queues[queueIndex++ % queues.length];
158
+ const job = await claim(queue);
159
+ if (job) {
160
+ found = true;
161
+ await processJob(job);
162
+ break;
163
+ }
164
+ }
165
+ if (!found && running) {
166
+ await wait();
167
+ }
168
+ }
169
+ catch (error) {
170
+ logger.error(error, 'redis job worker error');
171
+ if (running) {
172
+ await wait();
173
+ }
174
+ }
175
+ }
176
+ })();
177
+ },
178
+ async stop() {
179
+ running = false;
180
+ wake?.();
181
+ await loop;
182
+ },
183
+ };
184
+ }
185
+ }
package/dist/routes.js CHANGED
@@ -180,6 +180,41 @@ export function convertPathToOperationId(method, path) {
180
180
  const camelizedPath = segments.join('');
181
181
  return `${method.toLowerCase()}${camelizedPath}`;
182
182
  }
183
+ const expressPathParameterPattern = /([/.])?:([A-Za-z0-9_]+)(?:\((?:\\.|[^)])*\))?(\?)?/g;
184
+ const expandOpenAPIPaths = (path) => {
185
+ let variants = [
186
+ { path: '', parameterNames: new Set() },
187
+ ];
188
+ let previousIndex = 0;
189
+ for (const match of path.matchAll(expressPathParameterPattern)) {
190
+ const [token, prefix = '', name, optional] = match;
191
+ const literal = path.slice(previousIndex, match.index);
192
+ variants = variants.flatMap((variant) => {
193
+ const basePath = variant.path + literal;
194
+ if (optional) {
195
+ return [
196
+ { path: basePath, parameterNames: new Set(variant.parameterNames) },
197
+ {
198
+ path: `${basePath}${prefix}{${name}}`,
199
+ parameterNames: new Set([...variant.parameterNames, name]),
200
+ },
201
+ ];
202
+ }
203
+ return [
204
+ {
205
+ path: `${basePath}${prefix}{${name}}`,
206
+ parameterNames: new Set([...variant.parameterNames, name]),
207
+ },
208
+ ];
209
+ });
210
+ previousIndex = match.index + token.length;
211
+ }
212
+ const suffix = path.slice(previousIndex);
213
+ return variants.map((variant) => ({
214
+ ...variant,
215
+ path: variant.path + suffix,
216
+ }));
217
+ };
183
218
  export function toOpenAPISpec(app, info = {
184
219
  title: 'API Documentation',
185
220
  version: '1.0.0',
@@ -196,31 +231,15 @@ export function toOpenAPISpec(app, info = {
196
231
  for (const tag of tags) {
197
232
  allTags.add(tag);
198
233
  }
199
- // Convert Express path params to OpenAPI path params
200
- const openApiPath = path.replace(/:([^/]+)/g, '{$1}');
201
- /* v8 ignore next -- @preserve */
202
- if (!paths[openApiPath]) {
203
- paths[openApiPath] = {};
204
- }
205
- const parameters = [];
206
- // Add path parameters
207
- if (params) {
208
- parameters.push(...Object.keys(params.properties).map((name) => ({
209
- name,
210
- in: 'path',
211
- /* v8 ignore start -- @preserve */
212
- required: params.required?.includes(name) ?? true,
213
- schema: params.properties[name],
214
- /* v8 ignore end -- @preserve */
215
- })));
216
- }
234
+ const pathVariants = expandOpenAPIPaths(path);
235
+ const queryParameters = [];
217
236
  // Add query parameters
218
237
  if (query && typeof query === 'object' && 'properties' in query) {
219
238
  const querySchema = query;
220
239
  /* v8 ignore next -- @preserve */
221
240
  const properties = querySchema.properties || {};
222
241
  /* v8 ignore next -- @preserve */
223
- parameters.push(...Object.keys(properties).map((name) => ({
242
+ queryParameters.push(...Object.keys(properties).map((name) => ({
224
243
  name,
225
244
  in: 'query',
226
245
  required: Array.isArray(querySchema.required) &&
@@ -228,59 +247,75 @@ export function toOpenAPISpec(app, info = {
228
247
  schema: properties[name],
229
248
  })));
230
249
  }
231
- paths[openApiPath][method.toLowerCase()] = {
232
- operationId: convertPathToOperationId(method, path), // Use original path, not openApiPath
233
- tags,
234
- parameters,
235
- ...(body && {
236
- requestBody: {
237
- content: {
238
- 'application/json': {
239
- schema: body,
250
+ for (const pathVariant of pathVariants) {
251
+ /* v8 ignore next -- @preserve */
252
+ if (!paths[pathVariant.path]) {
253
+ paths[pathVariant.path] = {};
254
+ }
255
+ const pathParameters = params
256
+ ? Object.keys(params.properties)
257
+ .filter((name) => pathVariant.parameterNames.has(name))
258
+ .map((name) => ({
259
+ name,
260
+ in: 'path',
261
+ required: true,
262
+ schema: params.properties[name],
263
+ }))
264
+ : [];
265
+ paths[pathVariant.path][method.toLowerCase()] = {
266
+ operationId: convertPathToOperationId(method, pathVariant.path),
267
+ tags,
268
+ parameters: [...pathParameters, ...queryParameters],
269
+ ...(body && {
270
+ requestBody: {
271
+ content: {
272
+ 'application/json': {
273
+ schema: body,
274
+ },
240
275
  },
241
276
  },
242
- },
243
- }),
244
- responses: {
245
- ...(() => {
246
- const code = String(successStatus ?? 200);
247
- if (response) {
277
+ }),
278
+ responses: {
279
+ ...(() => {
280
+ const code = String(successStatus ?? 200);
281
+ if (response) {
282
+ return {
283
+ [code]: {
284
+ description: 'Successful response',
285
+ content: {
286
+ 'application/json': {
287
+ schema: response,
288
+ },
289
+ },
290
+ },
291
+ };
292
+ }
248
293
  return {
249
294
  [code]: {
250
295
  description: 'Successful response',
251
- content: {
252
- 'application/json': {
253
- schema: response,
254
- },
255
- },
256
296
  },
257
297
  };
258
- }
259
- return {
260
- [code]: {
261
- description: 'Successful response',
262
- },
263
- };
264
- })(),
265
- '400': {
266
- description: 'Bad Request',
267
- content: {
268
- 'application/json': {
269
- schema: {
270
- type: 'object',
271
- properties: {
272
- type: { type: 'string' },
273
- title: { type: 'string' },
274
- detail: { type: 'string' },
275
- status: { type: 'number' },
298
+ })(),
299
+ '400': {
300
+ description: 'Bad Request',
301
+ content: {
302
+ 'application/json': {
303
+ schema: {
304
+ type: 'object',
305
+ properties: {
306
+ type: { type: 'string' },
307
+ title: { type: 'string' },
308
+ detail: { type: 'string' },
309
+ status: { type: 'number' },
310
+ },
311
+ required: ['type', 'title', 'status'],
276
312
  },
277
- required: ['type', 'title', 'status'],
278
313
  },
279
314
  },
280
315
  },
281
316
  },
282
- },
283
- };
317
+ };
318
+ }
284
319
  }
285
320
  return {
286
321
  openapi: '3.1.0',
package/dist/streams.js CHANGED
@@ -3,7 +3,7 @@ export function writeChunk(writable, chunk, state) {
3
3
  // If we're already draining, just enqueue.
4
4
  if (state.draining) {
5
5
  if (state.queue.length >= state.maxQueue) {
6
- // Drop the oldest chunk to keep memory bounded.
6
+ // Queue entries are atomic: callers should enqueue complete protocol units.
7
7
  state.queue.shift();
8
8
  }
9
9
  state.queue.push(chunk);
package/dist/validate.js CHANGED
@@ -25,15 +25,15 @@ export async function validateSchema(data, schema) {
25
25
  await standardValidate(schema, data);
26
26
  }
27
27
  catch (error) {
28
+ if (!(error instanceof ValidationError)) {
29
+ throw error;
30
+ }
28
31
  return {
29
32
  isValid: false,
30
33
  /* v8 ignore start -- @preserve */
31
- errors: (error instanceof ValidationError
32
- ? error.issues.map((issue) => [
33
- String(issue.path ?? '/'),
34
- issue.message,
35
- ])
36
- : []).reduce((result, item) => {
34
+ errors: error.issues
35
+ .map((issue) => [String(issue.path ?? '/'), issue.message])
36
+ .reduce((result, item) => {
37
37
  const [key, value] = item;
38
38
  if (Array.isArray(result[key])) {
39
39
  result[key].push(value);
@@ -1,7 +1,6 @@
1
1
  import { type UserConfig } from 'vite';
2
2
  export declare function defineViteConfig({ base, ssr, analyze, }: {
3
3
  base?: string;
4
- reactCompiler?: boolean;
5
4
  ssr?: boolean;
6
5
  analyze?: boolean;
7
6
  }): UserConfig;
@@ -1,3 +1,4 @@
1
+ import { type Server } from 'node:http';
1
2
  import { type RequestHandler } from 'express';
2
3
  export interface SsrMiddlewareOptions<InitialData = unknown> {
3
4
  /**
@@ -14,6 +15,21 @@ export interface SsrMiddlewareOptions<InitialData = unknown> {
14
15
  * Uses `config.VITE_USE_DEV_SERVER` if not provided.
15
16
  */
16
17
  useDevServer?: boolean;
18
+ /**
19
+ * The app's HTTP server. When given, the dev server runs HMR on it instead
20
+ * of on a separate port, so HMR works behind a proxy (e.g. over https) and
21
+ * several dev servers can run side by side.
22
+ */
23
+ server?: Server;
24
+ /**
25
+ * Whether the dev server should serve a browser: run HMR and pre-bundle
26
+ * client dependencies.
27
+ *
28
+ * Defaults to `false` under Vitest, where several test workers each start a
29
+ * dev server and would otherwise fight over the HMR port and race on the
30
+ * dependency cache.
31
+ */
32
+ hmr?: boolean;
17
33
  }
18
34
  export interface SsrMiddleware extends RequestHandler {
19
35
  }
@@ -22,5 +38,5 @@ export interface SsrMiddleware extends RequestHandler {
22
38
  *
23
39
  * See more: https://vite.dev/guide/ssr
24
40
  */
25
- export declare function createSsrMiddleware<InitialData = unknown>({ root, getInitialData, useDevServer, }?: SsrMiddlewareOptions<InitialData>): Promise<SsrMiddleware>;
41
+ export declare function createSsrMiddleware<InitialData = unknown>({ root, getInitialData, useDevServer, server, hmr, }?: SsrMiddlewareOptions<InitialData>): Promise<SsrMiddleware>;
26
42
  export declare function replaceSsrPlaceholders(template: string, values: Record<string, unknown>): string;
package/dist/vite/ssr.js CHANGED
@@ -7,6 +7,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
7
7
  return path;
8
8
  };
9
9
  import * as fs from 'node:fs/promises';
10
+ import {} from 'node:http';
10
11
  import * as path from 'node:path';
11
12
  import { config } from '@devmoods/config';
12
13
  import express, {} from 'express';
@@ -21,7 +22,7 @@ import { createFetchRequest } from '../webapi.js';
21
22
  *
22
23
  * See more: https://vite.dev/guide/ssr
23
24
  */
24
- export async function createSsrMiddleware({ root = process.cwd(), getInitialData, useDevServer = config.VITE_USE_DEV_SERVER, } = {}) {
25
+ export async function createSsrMiddleware({ root = process.cwd(), getInitialData, useDevServer = config.VITE_USE_DEV_SERVER, server, hmr = !process.env.VITEST, } = {}) {
25
26
  const resolve = (p) => path.resolve(root, p);
26
27
  let vite;
27
28
  let assetLinks = { preload: '', css: '', js: '' };
@@ -29,7 +30,13 @@ export async function createSsrMiddleware({ root = process.cwd(), getInitialData
29
30
  if (useDevServer) {
30
31
  vite = await import('vite').then((mod) => mod.createServer({
31
32
  root,
32
- server: { middlewareMode: true },
33
+ server: {
34
+ middlewareMode: true,
35
+ ...(hmr
36
+ ? websocketServerOptions(mod.version, server)
37
+ : { hmr: false, ws: false }),
38
+ },
39
+ ...(!hmr && { optimizeDeps: { noDiscovery: true } }),
33
40
  appType: 'custom',
34
41
  }));
35
42
  middlewares.push(vite.middlewares);
@@ -103,6 +110,18 @@ export async function createSsrMiddleware({ root = process.cwd(), getInitialData
103
110
  }));
104
111
  return composeMiddleware(middlewares);
105
112
  }
113
+ /**
114
+ * Runs the HMR websocket on the given server. Vite 8 moved the option from
115
+ * `server.hmr.server` to `server.ws.server`.
116
+ */
117
+ function websocketServerOptions(viteVersion, server) {
118
+ if (!server) {
119
+ return {};
120
+ }
121
+ return Number.parseInt(viteVersion, 10) >= 8
122
+ ? { ws: { server } }
123
+ : { hmr: { server } };
124
+ }
106
125
  export function replaceSsrPlaceholders(template, values) {
107
126
  return template.replace(/\{\{\s*([\w-]+)\s*\}\}/g, (match, variableName) => {
108
127
  if (Object.prototype.hasOwnProperty.call(values, variableName)) {
@@ -1,15 +1,26 @@
1
1
  import { type ExpectStatic } from 'vitest';
2
- import failOnConsole from 'vitest-fail-on-console';
3
2
  interface CustomMatchers<R = unknown> {
4
3
  toMatchResponse: (statusCode: number, body?: unknown) => R;
5
4
  }
6
5
  declare module 'vitest' {
7
- interface Matchers<T = any> extends CustomMatchers<T> {
8
- }
9
- interface Assertion<T = any> extends CustomMatchers<T> {
6
+ interface Matchers<R extends void | Promise<void> = void | Promise<void>, T = unknown> extends CustomMatchers<R> {
10
7
  }
11
8
  interface AsymmetricMatchersContaining extends CustomMatchers {
12
9
  }
13
10
  }
14
- export { failOnConsole };
11
+ type ConsoleMethod = 'assert' | 'debug' | 'error' | 'info' | 'log' | 'warn';
12
+ interface FailOnConsoleOptions {
13
+ shouldFailOnAssert?: boolean;
14
+ shouldFailOnDebug?: boolean;
15
+ shouldFailOnError?: boolean;
16
+ shouldFailOnInfo?: boolean;
17
+ shouldFailOnLog?: boolean;
18
+ shouldFailOnWarn?: boolean;
19
+ allowMessage?: (message: string, method: ConsoleMethod) => boolean;
20
+ silenceMessage?: (message: string, method: ConsoleMethod) => boolean;
21
+ shouldPrintMessage?: boolean;
22
+ afterEachDelay?: number;
23
+ }
24
+ export declare function failOnConsole(options?: FailOnConsoleOptions): void;
15
25
  export declare function extendMatchers(expect: ExpectStatic): void;
26
+ export {};
@@ -1,6 +1,69 @@
1
- import {} from 'vitest';
2
- import failOnConsole from 'vitest-fail-on-console';
3
- export { failOnConsole };
1
+ import { format } from 'node:util';
2
+ import { afterEach, beforeEach } from 'vitest';
3
+ const defaultFailOnConsoleOptions = {
4
+ shouldFailOnAssert: false,
5
+ shouldFailOnDebug: false,
6
+ shouldFailOnError: true,
7
+ shouldFailOnInfo: false,
8
+ shouldFailOnLog: false,
9
+ shouldFailOnWarn: true,
10
+ };
11
+ const consoleMethodOptions = [
12
+ ['assert', 'shouldFailOnAssert'],
13
+ ['debug', 'shouldFailOnDebug'],
14
+ ['error', 'shouldFailOnError'],
15
+ ['info', 'shouldFailOnInfo'],
16
+ ['log', 'shouldFailOnLog'],
17
+ ['warn', 'shouldFailOnWarn'],
18
+ ];
19
+ export function failOnConsole(options = {}) {
20
+ const resolvedOptions = { ...defaultFailOnConsoleOptions, ...options };
21
+ for (const [method, optionName] of consoleMethodOptions) {
22
+ if (!resolvedOptions[optionName]) {
23
+ continue;
24
+ }
25
+ const calls = [];
26
+ const consoleMethods = console;
27
+ const original = consoleMethods[method];
28
+ beforeEach(() => {
29
+ calls.length = 0;
30
+ consoleMethods[method] = (...args) => {
31
+ /* v8 ignore next 3 -- covered by console.assert itself */
32
+ if (method === 'assert' && args[0]) {
33
+ return;
34
+ }
35
+ /* v8 ignore next -- covered by console.assert itself */
36
+ const messageArgs = method === 'assert' ? args.slice(1) : args;
37
+ /* v8 ignore next -- console.assert with no message */
38
+ const message = messageArgs.length > 0 ? format(...messageArgs) : 'Assertion failed';
39
+ /* v8 ignore next 3 -- optional diagnostic passthrough */
40
+ if (resolvedOptions.silenceMessage?.(message, method)) {
41
+ return;
42
+ }
43
+ /* v8 ignore next 4 -- optional diagnostic passthrough */
44
+ if (resolvedOptions.allowMessage?.(message, method)) {
45
+ original(...args);
46
+ return;
47
+ }
48
+ /* v8 ignore next 3 -- optional diagnostic passthrough */
49
+ if (resolvedOptions.shouldPrintMessage) {
50
+ original(...args);
51
+ }
52
+ calls.push(message);
53
+ };
54
+ });
55
+ afterEach(async () => {
56
+ /* v8 ignore next 5 -- optional async console flush delay */
57
+ if (resolvedOptions.afterEachDelay) {
58
+ await new Promise((resolve) => setTimeout(resolve, resolvedOptions.afterEachDelay));
59
+ }
60
+ consoleMethods[method] = original;
61
+ if (calls.length > 0) {
62
+ throw new Error(`Expected test not to call console.${method}().\n\n${calls.join('\n\n')}`);
63
+ }
64
+ });
65
+ }
66
+ }
4
67
  /* v8 ignore next -- @preserve */
5
68
  export function extendMatchers(expect) {
6
69
  expect.extend({
@@ -75,10 +75,22 @@ export async function createTestClient(server, options) {
75
75
  });
76
76
  };
77
77
  delete_.intercept = fetch.intercept;
78
- const close = async () => {
79
- if (started) {
80
- server.close();
78
+ let closePromise;
79
+ const close = () => {
80
+ if (!started) {
81
+ return Promise.resolve();
81
82
  }
83
+ closePromise ??= new Promise((resolve, reject) => {
84
+ server.close((error) => {
85
+ if (error) {
86
+ reject(error);
87
+ }
88
+ else {
89
+ resolve();
90
+ }
91
+ });
92
+ });
93
+ return closePromise;
82
94
  };
83
95
  return {
84
96
  duplicate() {