@devmoods/express-extras 0.76.0 → 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.
- package/dist/auth/contentNegotiation.d.ts +2 -0
- package/dist/auth/contentNegotiation.js +9 -0
- package/dist/auth/google.js +2 -1
- package/dist/auth/passwordless.js +2 -1
- package/dist/auth/tokenStorage.d.ts +2 -2
- package/dist/auth/tokenStorage.js +4 -4
- package/dist/auth/types.d.ts +2 -1
- package/dist/auth.js +27 -20
- package/dist/cache.js +4 -4
- package/dist/channels.d.ts +26 -3
- package/dist/channels.js +231 -56
- package/dist/cli.js +1 -0
- package/dist/context.js +6 -3
- package/dist/email.d.ts +1 -1
- package/dist/faktory.d.ts +12 -25
- package/dist/faktory.js +39 -51
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/jobs.d.ts +52 -0
- package/dist/jobs.js +58 -0
- package/dist/listen.js +37 -6
- package/dist/rate-limit.d.ts +11 -3
- package/dist/rate-limit.js +52 -22
- package/dist/redis-jobs.d.ts +23 -0
- package/dist/redis-jobs.js +185 -0
- package/dist/routes.js +95 -60
- package/dist/streams.js +1 -1
- package/dist/validate.js +6 -6
- package/dist/vite/defineViteConfig.d.ts +0 -1
- package/dist/vite/ssr.d.ts +17 -1
- package/dist/vite/ssr.js +21 -2
- package/dist/vitest/matchers.d.ts +16 -5
- package/dist/vitest/matchers.js +66 -3
- package/dist/vitest/testClient.js +15 -3
- package/package.json +24 -24
|
@@ -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
|
-
|
|
200
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
//
|
|
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:
|
|
32
|
-
|
|
33
|
-
|
|
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);
|
package/dist/vite/ssr.d.ts
CHANGED
|
@@ -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: {
|
|
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 =
|
|
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
|
-
|
|
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 {};
|
package/dist/vitest/matchers.js
CHANGED
|
@@ -1,6 +1,69 @@
|
|
|
1
|
-
import {} from '
|
|
2
|
-
import
|
|
3
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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() {
|